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

11,584 lines 499.6 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 // Seed defaults on init, not here — the constructor runs on
35 // plugins_loaded, before the mxchat textdomain loads (init, priority 10),
36 // so translating the default strings here both triggers WP 6.7's
37 // _load_textdomain_just_in_time notice and stores the untranslated
38 // English fallbacks. Priority 20 lands after mxchat_load_textdomain().
39 if (!$this->options) {
40 add_action('init', array($this, 'initialize_default_options'), 20);
41 }
42
43 // Add admin menu and initialize settings
44 add_action('admin_menu', array($this, 'mxchat_add_plugin_page'));
45 // Pro & Extensions registers at priority 30 so it always lands last in
46 // the sidebar — below API Access (priority 20) and below any other
47 // submenu that hooks at default priority 10.
48 add_action('admin_menu', array($this, 'mxchat_add_pro_extensions_page'), 30);
49 // Onboarding visibility — runs LAST so it can remove the submenu after every
50 // other add_submenu_page() call. The page stays reachable by direct URL.
51 add_action('admin_menu', array($this, 'mxchat_apply_onboarding_visibility'), 999);
52 add_action('admin_init', array($this, 'mxchat_page_init'));
53 add_action('admin_init', array($this, 'mxchat_prompts_page_init'));
54 add_action('admin_enqueue_scripts', array($this, 'mxchat_enqueue_admin_assets'));
55 // Add body class for the Onboarding wizard (plan-905439) so the
56 // chrome-surgery CSS in admin-onboarding-wizard.css can scope its
57 // WP-sidebar collapse to this page only.
58 add_filter('admin_body_class', array($this, 'mxchat_add_onboarding_body_class'));
59 add_action('wp_ajax_mxchat_delete_chat_history', array($this, 'mxchat_delete_chat_history'));
60 add_action('admin_post_mxchat_delete_prompt', array($this, 'mxchat_handle_delete_prompt'));
61 add_action('wp_ajax_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history'));
62 add_action('wp_ajax_nopriv_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history'));
63 add_action('wp_ajax_mxchat_fetch_conversation', array($this, 'mxchat_fetch_conversation'));
64 add_action('wp_footer', array($this, 'mxchat_append_chatbot_to_body'));
65 add_action('admin_head-mxchat-prompts', array($this, 'mxchat_enqueue_admin_assets'));
66 add_action('admin_head-toplevel_page_mxchat-max', array($this, 'mxchat_enqueue_admin_assets'));
67 add_action('admin_notices', array($this, 'mxchat_display_admin_notice'));
68 add_action('admin_post_mxchat_delete_all_prompts', array($this, 'mxchat_handle_delete_all_prompts'));
69 add_action('admin_post_mxchat_add_intent', array($this, 'mxchat_handle_add_intent'));
70 add_action('admin_post_mxchat_delete_intent', array($this, 'mxchat_handle_delete_intent'));
71 add_action('admin_post_mxchat_edit_intent', array($this, 'mxchat_handle_edit_intent'));
72 add_action('wp_ajax_mxchat_export_transcripts', array($this, 'export_chat_transcripts'));
73
74 // Leads tab (inside Transcripts)
75 add_action('wp_ajax_mxchat_fetch_leads', array($this, 'mxchat_fetch_leads'));
76 add_action('wp_ajax_mxchat_delete_leads', array($this, 'mxchat_delete_leads'));
77 add_action('wp_ajax_mxchat_export_leads', array($this, 'mxchat_export_leads'));
78
79 add_action('admin_init', array($this, 'mxchat_transcripts_page_init'));
80 add_action('wp_ajax_dismiss_live_agent_notice', array($this, 'dismiss_live_agent_notice'));
81 add_action('wp_ajax_dismiss_theme_migration_notice', array($this, 'dismiss_theme_migration_notice'));
82 add_action('mxchat_cleanup_old_transcripts', array($this, 'cleanup_old_transcripts'));
83 // Self-heal: the cleanup event is otherwise only scheduled at activation or
84 // when the retention setting CHANGES — if it is ever lost (deactivate cycle,
85 // cron-option wipe, DB restore) nothing re-registers it and retention goes
86 // silently inert while the UI still says it is on (plan-bc08a6).
87 add_action('admin_init', array($this, 'ensure_transcript_cleanup_scheduled'));
88
89 add_action('admin_init', array($this, 'register_pinecone_settings'));
90 add_action('admin_init', array($this, 'register_openai_vectorstore_settings'));
91
92 add_action('admin_notices', array($this, 'display_admin_notices'));
93
94 add_action('wp_ajax_mxchat_test_streaming_actual', [$this, 'mxchat_handle_test_streaming_actual']);
95 add_action('wp_ajax_mxchat_test_streaming', [$this, 'mxchat_handle_test_streaming']); // Keep existing as fallback
96 add_action('wp_ajax_mxchat_test_vectorstore_connection', array($this, 'mxchat_test_vectorstore_connection'));
97
98 // wp_ajax_mxchat_save_selected_bot is registered (and handled) in
99 // admin/class-ajax-handler.php — a duplicate registration here pointed
100 // at a method this class never defined, a load-order-dependent fatal
101 // waiting to fire (plan 52bcad).
102 add_action('wp_ajax_mxchat_fetch_openrouter_models', array($this, 'fetch_openrouter_models'));
103 add_action('wp_ajax_mxchat_get_rag_context', array($this, 'mxchat_get_rag_context'));
104
105 // Actions page AJAX handlers
106 add_action('wp_ajax_mxchat_fetch_actions_list', array($this, 'mxchat_fetch_actions_list'));
107 add_action('wp_ajax_mxchat_toggle_action_status', array($this, 'mxchat_toggle_action_status'));
108 add_action('wp_ajax_mxchat_bulk_delete_actions', array($this, 'mxchat_bulk_delete_actions'));
109 add_action('wp_ajax_mxchat_add_intent_ajax', array($this, 'mxchat_add_intent_ajax'));
110 add_action('wp_ajax_mxchat_edit_intent_ajax', array($this, 'mxchat_edit_intent_ajax'));
111 add_action('wp_ajax_mxchat_delete_intent_ajax', array($this, 'mxchat_delete_intent_ajax'));
112 add_action('wp_ajax_mxchat_add_phrase', array($this, 'mxchat_add_phrase_ajax'));
113 add_action('wp_ajax_mxchat_delete_phrase', array($this, 'mxchat_delete_phrase_ajax'));
114 add_action('wp_ajax_mxchat_get_phrases', array($this, 'mxchat_get_phrases_ajax'));
115 add_action('wp_ajax_mxchat_delete_legacy_phrases', array($this, 'mxchat_delete_legacy_phrases_ajax'));
116
117 // Slack test connection
118 add_action('wp_ajax_mxchat_test_slack_connection', array($this, 'mxchat_test_slack_connection'));
119
120 // Translation handlers
121 add_action('wp_ajax_mxchat_translate_messages', array($this, 'mxchat_translate_messages'));
122 add_action('wp_ajax_mxchat_get_transcript_translation', array($this, 'mxchat_get_transcript_translation'));
123
124 // 3.2.3: Embedding model switch protection
125 add_action('wp_ajax_mxchat_check_embedding_switch', array($this, 'mxchat_check_embedding_switch_ajax'));
126 add_action('wp_ajax_mxchat_dismiss_embedding_mismatch', array($this, 'mxchat_dismiss_embedding_mismatch_ajax'));
127 add_action('wp_ajax_mxchat_dismiss_telegram_secret_notice', array($this, 'mxchat_dismiss_telegram_secret_notice_ajax'));
128 add_action('admin_notices', array($this, 'mxchat_embedding_mismatch_notice'));
129 add_action('admin_notices', array($this, 'mxchat_telegram_secret_notice'));
130 }
131
132 /**
133 * Nudge admins to configure a Telegram webhook secret (plan-0c17b5).
134 *
135 * Shown only when a Telegram bot token is configured (integration active)
136 * AND no webhook secret is set. Without a secret the webhook falls back to a
137 * Telegram source-IP check — safer than the old fail-open, but a real secret
138 * is the recommended protection. Dismissible; does not block anything.
139 */
140 public function mxchat_telegram_secret_notice() {
141 if (!current_user_can('manage_options')) {
142 return;
143 }
144 $options = get_option('mxchat_options', array());
145 $has_token = !empty($options['telegram_bot_token']);
146 $has_secret = !empty($options['telegram_webhook_secret']);
147 if (!$has_token) {
148 return;
149 }
150
151 // Inbound rejections recorded by verify_telegram_request() (plan 30398e).
152 // When the webhook is actually being rejected this stops being a hardening
153 // suggestion and becomes an outage report: it shows even when a secret IS
154 // set (a wrong or unregistered secret rejects just as hard as none), and it
155 // ignores the dismissal flag, because a nudge someone dismissed in July
156 // must not silence live breakage. It self-clears on the first accepted
157 // request, so a fixed install stops nagging without anyone touching it.
158 $rejects = get_option('mxchat_telegram_webhook_rejects', array());
159 $reject_count = (is_array($rejects) && !empty($rejects['count'])) ? (int) $rejects['count'] : 0;
160
161 if ($reject_count < 1) {
162 // Nothing is failing — the original advisory, unchanged.
163 if ($has_secret) {
164 return;
165 }
166 if (get_option('mxchat_dismissed_telegram_secret_notice', '') === '1') {
167 return;
168 }
169 }
170
171 $settings_url = admin_url('admin.php?page=mxchat-settings#integrations-telegram');
172 ?>
173 <?php if ($reject_count > 0) : ?>
174 <div class="notice notice-error mxchat-telegram-reject-notice">
175 <p><strong><?php esc_html_e('MxChat: Telegram is sending replies your site is rejecting', 'mxchat'); ?></strong></p>
176 <p>
177 <?php
178 echo esc_html(sprintf(
179 /* translators: 1: number of rejected webhook requests, 2: local date and time of the most recent one */
180 _n(
181 '%1$s incoming webhook request has been rejected (most recently %2$s), so agent replies from Telegram are not reaching your visitors.',
182 '%1$s incoming webhook requests have been rejected (most recently %2$s), so agent replies from Telegram are not reaching your visitors.',
183 $reject_count,
184 'mxchat'
185 ),
186 number_format_i18n($reject_count),
187 !empty($rejects['last_ts'])
188 ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $rejects['last_ts'])
189 : __('unknown time', 'mxchat')
190 ));
191 ?>
192 </p>
193 <p><?php echo esc_html($this->mxchat_telegram_reject_advice($rejects['last_reason'] ?? '')); ?></p>
194 <p>
195 <a href="<?php echo esc_url($settings_url); ?>" class="button button-primary"><?php esc_html_e('Open Telegram settings', 'mxchat'); ?></a>
196 </p>
197 </div>
198 <?php else : ?>
199 <div class="notice notice-warning is-dismissible mxchat-telegram-secret-notice">
200 <p><strong><?php esc_html_e('MxChat: Telegram webhook is running without a secret token', 'mxchat'); ?></strong></p>
201 <p>
202 <?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'); ?>
203 </p>
204 <p>
205 <a href="<?php echo esc_url($settings_url); ?>" class="button button-primary"><?php esc_html_e('Open Telegram settings', 'mxchat'); ?></a>
206 </p>
207 </div>
208 <?php endif; ?>
209 <script>
210 (function(){
211 var n = document.querySelector('.mxchat-telegram-secret-notice');
212 if (!n) return;
213 n.addEventListener('click', function(e){
214 if (e.target && e.target.classList.contains('notice-dismiss')) {
215 var d = new FormData();
216 d.append('action', 'mxchat_dismiss_telegram_secret_notice');
217 d.append('nonce', '<?php echo esc_js(wp_create_nonce('mxchat_dismiss_telegram_secret')); ?>');
218 fetch('<?php echo esc_url(admin_url('admin-ajax.php')); ?>', { method: 'POST', body: d, credentials: 'same-origin' });
219 }
220 });
221 })();
222 </script>
223 <?php
224 }
225
226 /**
227 * Turn a webhook rejection reason code into the sentence that tells the site
228 * owner what to actually do (plan 30398e). The three reasons map to three
229 * different fixes, and saying which one applies is most of the value; shared
230 * by the admin notice and the Telegram settings readout so there is one
231 * wording, not two.
232 *
233 * @param string $reason Reason code recorded by verify_telegram_request().
234 * @return string
235 */
236 public function mxchat_telegram_reject_advice($reason) {
237 switch ((string) $reason) {
238 case 'no_secret_ip_mismatch':
239 return __('Requests are arriving from an address outside Telegram\'s published ranges, which is normal behind Cloudflare or a reverse proxy. Set a webhook secret in the Telegram settings and re-register your webhook.', 'mxchat');
240 case 'missing_header':
241 return __('Your webhook was registered without the secret. Open the Register Webhook URL link on the Telegram settings tab again — it now includes the secret token.', 'mxchat');
242 case 'secret_mismatch':
243 return __('The secret Telegram is sending does not match the one saved here. Save your settings, then re-register the webhook using the link on the Telegram settings tab.', 'mxchat');
244 default:
245 return __('Re-register your webhook from the Telegram settings tab.', 'mxchat');
246 }
247 }
248
249 /**
250 * AJAX: persist dismissal of the Telegram-secret admin notice (plan-0c17b5).
251 */
252 public function mxchat_dismiss_telegram_secret_notice_ajax() {
253 if (!current_user_can('manage_options')
254 || !check_ajax_referer('mxchat_dismiss_telegram_secret', 'nonce', false)) {
255 wp_send_json_error();
256 }
257 update_option('mxchat_dismissed_telegram_secret_notice', '1', 'no');
258 wp_send_json_success();
259 }
260
261 /**
262 * 3.2.3: Preflight check before allowing the embedding model dropdown to
263 * switch. Pure option comparison — no counts, no DB queries beyond the
264 * cached options. The dialog is shown whenever the user has previously
265 * embedded with a different model than the one they're switching to.
266 */
267 public function mxchat_check_embedding_switch_ajax() {
268 check_ajax_referer('mxchat_admin_nonce', 'security');
269 if (!current_user_can('manage_options')) {
270 wp_send_json_error(__('Unauthorized', 'mxchat'));
271 }
272
273 $new_model = isset($_POST['new_model']) ? sanitize_text_field(wp_unslash($_POST['new_model'])) : '';
274 $options = get_option('mxchat_options', array());
275 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
276 // The standard dropdown is inert while custom embeddings are on —
277 // whatever value it posts, the next embed uses the custom identity.
278 $new_model = MxChat_Utils::get_selected_embedding_model($options);
279 }
280 $active_model = MxChat_Utils::get_active_embedding_model();
281
282 $is_mismatch = !empty($active_model) && !empty($new_model) && $active_model !== $new_model;
283
284 $active_dims = MxChat_Utils::embedding_model_dimensions($active_model);
285 $new_dims = MxChat_Utils::embedding_model_dimensions($new_model);
286
287 wp_send_json_success(array(
288 'is_mismatch' => $is_mismatch,
289 'active_model' => $active_model,
290 'active_label' => MxChat_Utils::embedding_model_label($active_model),
291 'new_model' => $new_model,
292 'new_label' => MxChat_Utils::embedding_model_label($new_model),
293 'dims_differ' => ($active_dims > 0 && $new_dims > 0 && $active_dims !== $new_dims),
294 'active_dims' => $active_dims,
295 'new_dims' => $new_dims,
296 ));
297 }
298
299 /**
300 * 3.2.3: Dismiss the persistent mismatch banner. Tied to the active+selected
301 * pair so the banner reappears on the next switch event.
302 */
303 public function mxchat_dismiss_embedding_mismatch_ajax() {
304 check_ajax_referer('mxchat_admin_nonce', 'security');
305 if (!current_user_can('manage_options')) {
306 wp_send_json_error(__('Unauthorized', 'mxchat'));
307 }
308
309 $options = get_option('mxchat_options', array());
310 $selected = MxChat_Utils::get_selected_embedding_model($options);
311 $active = MxChat_Utils::get_active_embedding_model();
312 update_option('mxchat_dismissed_embedding_mismatch', $active . '|' . $selected, false);
313 wp_send_json_success();
314 }
315
316 /**
317 * 3.2.3: Persistent admin banner shown whenever the active embedding model
318 * (last used to actually embed something) differs from the currently
319 * selected model. Pure option comparison — no DB queries on every page
320 * load. The banner auto-clears once both match again, i.e. after a delete
321 * + re-embed cycle.
322 */
323 public function mxchat_embedding_mismatch_notice() {
324 if (!current_user_can('manage_options')) {
325 return;
326 }
327
328 $options = get_option('mxchat_options', array());
329 $selected = MxChat_Utils::get_selected_embedding_model($options);
330 $active = MxChat_Utils::get_active_embedding_model();
331
332 if (empty($active) || empty($selected) || $active === $selected) {
333 return;
334 }
335
336 $dismissed = get_option('mxchat_dismissed_embedding_mismatch', '');
337 if ($dismissed === $active . '|' . $selected) {
338 return;
339 }
340
341 $active_label = MxChat_Utils::embedding_model_label($active);
342 $selected_label = MxChat_Utils::embedding_model_label($selected);
343 $active_dims = MxChat_Utils::embedding_model_dimensions($active);
344 $selected_dims = MxChat_Utils::embedding_model_dimensions($selected);
345 $dims_differ = ($active_dims > 0 && $selected_dims > 0 && $active_dims !== $selected_dims);
346
347 $kb_url = admin_url('admin.php?page=mxchat-prompts');
348 $actions_url = admin_url('admin.php?page=mxchat-actions');
349
350 ?>
351 <div class="notice notice-error is-dismissible mxchat-embedding-mismatch-notice"
352 data-active="<?php echo esc_attr($active); ?>"
353 data-selected="<?php echo esc_attr($selected); ?>">
354 <p><strong><?php esc_html_e('MxChat: Embedding model mismatch detected', 'mxchat'); ?></strong></p>
355 <p>
356 <?php
357 printf(
358 /* translators: 1: previously-used model name, 2: currently-selected model name */
359 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'),
360 '<code>' . esc_html($active_label) . '</code>',
361 '<code>' . esc_html($selected_label) . '</code>'
362 );
363 ?>
364 </p>
365 <?php if ($dims_differ) : ?>
366 <p>
367 <strong><?php esc_html_e('Dimension mismatch:', 'mxchat'); ?></strong>
368 <?php
369 printf(
370 /* translators: 1: old dim count, 2: new dim count */
371 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'),
372 (int) $active_dims,
373 (int) $selected_dims
374 );
375 ?>
376 </p>
377 <?php endif; ?>
378 <p>
379 <?php esc_html_e('To fix this:', 'mxchat'); ?>
380 <a href="<?php echo esc_url($kb_url); ?>"><?php esc_html_e('Delete all knowledge base entries', 'mxchat'); ?></a> ·
381 <a href="<?php echo esc_url($actions_url); ?>"><?php esc_html_e('Delete all actions', 'mxchat'); ?></a> ·
382 <?php esc_html_e('then re-import / re-add them with the new model selected.', 'mxchat'); ?>
383 </p>
384 </div>
385 <script>
386 (function($){
387 $(document).on('click', '.mxchat-embedding-mismatch-notice .notice-dismiss', function(){
388 $.post(ajaxurl, {
389 action: 'mxchat_dismiss_embedding_mismatch',
390 security: '<?php echo esc_js(wp_create_nonce('mxchat_admin_nonce')); ?>'
391 });
392 });
393 })(jQuery);
394 </script>
395 <?php
396 }
397
398 private function is_license_active() {
399 $license_status = get_option('mxchat_license_status', 'inactive');
400 return ($license_status === 'active');
401 }
402
403 public function initialize_default_options() {
404 // Re-check under the hook: another request may have seeded the option
405 // between the constructor's check on plugins_loaded and init firing.
406 if (get_option('mxchat_options')) {
407 return;
408 }
409 $default_options = array(
410 'api_key' => '',
411 'xai_api_key' => '',
412 'claude_api_key' => '',
413 'deepseek_api_key' => '',
414 'voyage_api_key' => '',
415 'gemini_api_key' => '',
416 'enable_streaming_toggle' => 'off',
417 'enable_web_search' => 'off',
418 'embedding_model' => 'text-embedding-ada-002',
419 '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:
420
421 # Response Style - CRITICALLY IMPORTANT
422 - MAXIMUM LENGTH: 1-3 short sentences per response
423 - Ultra-concise: Get straight to the answer with no filler
424 - No introductions like "Sure!" or "I\'d be happy to help"
425 - No phrases like "based on my knowledge" or "according to information"
426 - No explanatory text before giving the answer
427 - No summaries or repetition
428 - Hyperlink all URLs
429 - Respond in user\'s language
430 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
431
432 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
433 - ONLY answer questions using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
434 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
435 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
436 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
437 - Better to admit insufficient information than provide inaccurate answers',
438 'model' => 'gpt-5.6-sol',
439 'rate_limit_logged_out' => '100',
440 'role_rate_limits' => array(),
441 'rate_limit_message' => esc_html__('Rate limit exceeded. Please try again later.', 'mxchat'),
442 'enable_email_block' => '',
443 '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'),
444 'email_blocker_button_text' => esc_html__('Start Chat', 'mxchat'),
445 'enable_name_field' => 'off', // NEW
446 'name_field_placeholder' => esc_html__('Enter your name', 'mxchat'), // NEW
447 'enable_consent_checkbox' => 'off', // b062c4 — default OFF: changes an existing form on every install
448 'consent_checkbox_label' => __('I agree to the Privacy Policy.', 'mxchat'),
449 'consent_checkbox_required' => 'off',
450 'top_bar_title' => esc_html__('MxChat', 'mxchat'),
451 'intro_message' => __('Hello! How can I assist you today?', 'mxchat'),
452 'ai_agent_text' => esc_html__('AI Agent', 'mxchat'),
453 'input_copy' => esc_html__('How can I assist?', 'mxchat'),
454 'append_to_body' => 'off',
455 'post_type_visibility_mode' => 'all', // 'all', 'include', 'exclude'
456 'post_type_visibility_list' => array(), // Array of post type slugs
457 'contextual_awareness_toggle' => 'off',
458 'citation_links_toggle' => 'on',
459 'satisfaction_rating_enabled' => 'off',
460 'satisfaction_rating_idle_seconds' => 60,
461 'satisfaction_rating_question' => '',
462 'satisfaction_rating_thanks' => '',
463 'satisfaction_rating_placeholder' => '',
464 'satisfaction_rating_saved' => '',
465 'close_button_color' => '#fff',
466 'chatbot_bg_color' => '#fff',
467 'user_message_bg_color' => '#fff',
468 'user_message_font_color' => '#212121',
469 'bot_message_bg_color' => '#212121',
470 'bot_message_font_color' => '#fff',
471 'top_bar_bg_color' => '#212121',
472 'send_button_font_color' => '#212121',
473 'chat_input_font_color' => '#212121',
474 'chatbot_background_color' => '#212121',
475 'icon_color' => '#fff',
476 'enable_woocommerce_integration' => '0',
477 'link_target_toggle' => 'off',
478 'pre_chat_message' => esc_html__('Hey there! Ask me anything!', 'mxchat'),
479
480 // New fields for Loops Integration
481 'loops_api_key' => '',
482 'loops_mailing_list' => '',
483 'triggered_phrase_response' => __('Would you like to join our mailing list? Please provide your email below.', 'mxchat'),
484 'email_capture_response' => __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat'),
485 'popular_question_1' => '',
486 'popular_question_2' => '',
487 'popular_question_3' => '',
488 'pdf_intent_trigger_text' => __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat'),
489 'pdf_intent_success_text' => __("I've processed the PDF. What questions do you have about it?", 'mxchat'),
490 'pdf_intent_error_text' => __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat'),
491 'pdf_max_pages' => 69,
492 'show_pdf_upload_button' => 'on',
493 'show_word_upload_button' => 'on',
494
495 // Live Agent Integration (Slack)
496 'live_agent_webhook_url' => '',
497 'live_agent_secret_key' => '',
498 'live_agent_bot_token' => '',
499 'live_agent_message_bg_color' => '#ffffff',
500 'live_agent_message_font_color' => '#333333',
501
502 // Telegram Integration
503 'telegram_status' => 'off',
504 'telegram_bot_token' => '',
505 'telegram_group_id' => '',
506 'telegram_webhook_secret' => '',
507 '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'),
508 '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'),
509
510 // Webhook handoff destination (plan d88e22, outbound-only)
511 'webhook_handoff_status' => 'off',
512 'webhook_handoff_url' => '',
513 'webhook_handoff_secret' => '',
514 'webhook_handoff_notification_message' => __("I've notified our support team — they'll follow up with you soon. Meanwhile, I'm happy to keep helping.", 'mxchat'),
515 'webhook_handoff_away_message' => __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat'),
516
517 'chat_toolbar_toggle' => 'off',
518 'mode_indicator_bg_color' => '#767676',
519 'mode_indicator_font_color' => '#ffffff',
520 'toolbar_icon_color' => '#212121',
521
522 // Optimization settings
523 'script_loading_strategy' => 'default',
524
525 // Debug settings
526 'debug_mode' => 'off',
527 );
528
529
530 // Merge existing options with defaults
531 $existing_options = get_option('mxchat_options', array());
532 $merged_options = wp_parse_args($existing_options, $default_options);
533
534 // Update the options if they have changed
535 if ($existing_options !== $merged_options) {
536 update_option('mxchat_options', $merged_options);
537 }
538
539 // Add default limits for each role
540 $roles = wp_roles()->get_names();
541 foreach ($roles as $role_id => $role_name) {
542 $default_options['role_rate_limits'][$role_id] = '100';
543 }
544
545 return $default_options;
546
547 // Update the $this->options property
548 $this->options = $merged_options;
549 }
550
551 public function mxchat_add_plugin_page() {
552 // Onboarding lifecycle helpers (admin_init redirect + ajax handlers live in the file).
553 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
554
555 // Main menu page — `mxchat-max` remains the parent slug for every MxChat submenu
556 // (Settings, Knowledge, Transcripts, …). Hitting `?page=mxchat-max` directly now
557 // dispatches to the Onboarding page (or Settings if the user has dismissed onboarding).
558 add_menu_page(
559 esc_html__('MxChat', 'mxchat'),
560 esc_html__('MxChat', 'mxchat'),
561 'manage_options',
562 'mxchat-max',
563 array($this, 'mxchat_create_dashboard_page'),
564 'dashicons-testimonial',
565 6
566 );
567
568 // Onboarding submenu — first child under MxChat (plan-d14e89).
569 // First registration uses menu_slug === parent slug 'mxchat-max' →
570 // WP-canonical override of the auto-duplicate "MxChat" entry. Result: the
571 // first child shows as "Onboarding" instead of a redundant pair.
572 add_submenu_page(
573 'mxchat-max',
574 esc_html__('MxChat Onboarding', 'mxchat'),
575 esc_html__('Onboarding', 'mxchat'),
576 'manage_options',
577 'mxchat-max',
578 array($this, 'mxchat_create_dashboard_page')
579 );
580 // Hidden route for `?page=mxchat-onboarding` (Settings "Show again" link
581 // + legacy redirects still target this slug). Parent === null keeps it
582 // out of the menu while remaining accessible by URL.
583 add_submenu_page(
584 null,
585 esc_html__('MxChat Onboarding', 'mxchat'),
586 esc_html__('Onboarding', 'mxchat'),
587 'manage_options',
588 'mxchat-onboarding',
589 array($this, 'mxchat_create_dashboard_page')
590 );
591
592 // Settings submenu — same callback as before, just at a new slug.
593 add_submenu_page(
594 'mxchat-max',
595 esc_html__('MxChat Settings', 'mxchat'),
596 esc_html__('Settings', 'mxchat'),
597 'manage_options',
598 'mxchat-settings',
599 array($this, 'mxchat_create_admin_page')
600 );
601
602 // Submenu page for Knowledge
603 add_submenu_page(
604 'mxchat-max',
605 esc_html__('Prompts', 'mxchat'),
606 esc_html__('Knowledge', 'mxchat'),
607 'manage_options',
608 'mxchat-prompts',
609 array($this, 'mxchat_create_prompts_page')
610 );
611
612 add_submenu_page(
613 'mxchat-max',
614 esc_html__('Chat Transcripts', 'mxchat'),
615 esc_html__('Transcripts', 'mxchat'),
616 'manage_options',
617 'mxchat-transcripts',
618 array($this, 'mxchat_create_transcripts_page')
619 );
620
621 add_submenu_page(
622 'mxchat-max',
623 esc_html__('MxChat Actions', 'mxchat'),
624 esc_html__('Actions', 'mxchat'),
625 'manage_options',
626 'mxchat-actions',
627 array($this, 'mxchat_actions_page_html')
628 );
629
630 // Content Generator page
631 add_submenu_page(
632 'mxchat-max',
633 esc_html__('Content', 'mxchat'),
634 esc_html__('Content', 'mxchat'),
635 'manage_options',
636 'mxchat-content',
637 array($this, 'mxchat_create_content_page')
638 );
639
640 }
641
642 /**
643 * Register the Pro & Extensions submenu on a later admin_menu priority so it
644 * always renders as the bottom-most item in the MxChat sidebar — below
645 * configuration pages like API Access (priority 20).
646 */
647 public function mxchat_add_pro_extensions_page() {
648 add_submenu_page(
649 'mxchat-max',
650 esc_html__('Pro & Extensions', 'mxchat'),
651 esc_html__('Pro & Extensions', 'mxchat'),
652 'manage_options',
653 'mxchat-activation',
654 array($this, 'mxchat_create_activation_page')
655 );
656 }
657
658 public function mxchat_create_addons_page() {
659 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
660 $addons_page = new MxChat_Addons();
661 $addons_page->render_page();
662 }
663
664 /**
665 * Render the Content Generator admin page
666 */
667 public function mxchat_create_content_page() {
668 require_once plugin_dir_path(__FILE__) . 'admin-content-page.php';
669 // The Editor Assistant toggle on the Settings tab renders through
670 // mxchat_render_field_wrapper(), which lives in admin-settings-page.php
671 // (plan-f7df40 relocation) — load it the same way the settings page does.
672 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
673 mxchat_render_content_page($this);
674 }
675
676 /**
677 * Test actual streaming functionality in the WordPress environment
678 */
679 public function mxchat_handle_test_streaming_actual() {
680 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
681
682 // A nonce is not authorization (plan-mxchat-20260731-c63fb6). This handler
683 // reads the site's provider keys and spends them on an outbound call, so it
684 // is billable abuse for any logged-in user who obtained the nonce.
685 if (!current_user_can('manage_options')) {
686 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
687 }
688
689 // Check if headers have already been sent
690 if (headers_sent()) {
691 wp_send_json_error(['message' => 'Headers already sent - streaming not possible']);
692 return;
693 }
694
695 // Check for required functions
696 if (!function_exists('curl_init')) {
697 wp_send_json_error(['message' => 'cURL not available - streaming requires cURL']);
698 return;
699 }
700
701 // Get user's selected model and API key
702 $options = get_option('mxchat_options', []);
703 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
704
705 // Get the provider from the model
706 $model_parts = explode('-', $selected_model);
707 $provider = strtolower($model_parts[0]);
708
709 // Get the appropriate API key
710 $api_key = '';
711 switch ($provider) {
712 case 'gpt':
713 case 'o1':
714 $api_key = $options['api_key'] ?? '';
715 break;
716 case 'claude':
717 $api_key = $options['claude_api_key'] ?? '';
718 break;
719 case 'grok':
720 $api_key = $options['xai_api_key'] ?? '';
721 break;
722 case 'deepseek':
723 $api_key = $options['deepseek_api_key'] ?? '';
724 break;
725 case 'gemini':
726 $api_key = $options['gemini_api_key'] ?? '';
727 break;
728 default:
729 // Default to OpenAI for unknown models
730 $api_key = $options['api_key'] ?? '';
731 $provider = 'gpt';
732 break;
733 }
734
735 if (empty($api_key)) {
736 wp_send_json_error(['message' => "API key not configured for {$provider} provider"]);
737 return;
738 }
739
740 // Test streaming with the selected model and provider
741 try {
742 $this->perform_streaming_test($provider, $selected_model, $api_key);
743 } catch (Exception $e) {
744 wp_send_json_error(['message' => 'Streaming test exception: ' . $e->getMessage()]);
745 }
746 }
747
748 /**
749 * Perform the actual streaming test
750 */
751 private function perform_streaming_test($provider, $model, $api_key) {
752 // Set streaming headers
753 header('Content-Type: text/event-stream');
754 header('Cache-Control: no-cache');
755 header('X-Accel-Buffering: no'); // Disable nginx buffering
756
757 // Prepare test message
758 $test_message = "Please respond with exactly: 'Streaming test successful!' - send this as a short response for testing.";
759
760 // Configure API request based on provider
761 $url = '';
762 $headers = [];
763 $body = [];
764
765 switch ($provider) {
766 case 'gpt':
767 case 'o1':
768 $url = 'https://api.openai.com/v1/chat/completions';
769 $headers = [
770 'Content-Type: application/json',
771 'Authorization: Bearer ' . $api_key
772 ];
773 $body = [
774 'model' => $model,
775 'messages' => [['role' => 'user', 'content' => $test_message]],
776 'max_tokens' => 50,
777 'temperature' => 0.3,
778 'stream' => true
779 ];
780 break;
781
782 case 'claude':
783 $url = 'https://api.anthropic.com/v1/messages';
784 $headers = [
785 'Content-Type: application/json',
786 'x-api-key: ' . $api_key,
787 'anthropic-version: 2023-06-01'
788 ];
789 $body = [
790 'model' => $model,
791 'messages' => [['role' => 'user', 'content' => $test_message]],
792 'max_tokens' => 50,
793 'temperature' => 0.3,
794 'stream' => true
795 ];
796 // Claude flagships from Opus 4.7 onward reject the temperature
797 // param outright (400). Reuse the catalog's decision — this path
798 // previously sent temperature unconditionally, so the streaming
799 // test was broken for Opus 4.7/4.8, Fable 5 and Sonnet 5.
800 if (class_exists('MxChat_Model_Catalog')
801 && method_exists('MxChat_Model_Catalog', 'supports_temperature')
802 && !MxChat_Model_Catalog::supports_temperature($model)) {
803 unset($body['temperature']);
804 }
805 break;
806
807 case 'grok':
808 $url = 'https://api.x.ai/v1/chat/completions';
809 $headers = [
810 'Content-Type: application/json',
811 'Authorization: Bearer ' . $api_key
812 ];
813 $body = [
814 'model' => $model,
815 'messages' => [['role' => 'user', 'content' => $test_message]],
816 'max_tokens' => 50,
817 'temperature' => 0.3,
818 'stream' => true
819 ];
820 break;
821
822 case 'deepseek':
823 $url = 'https://api.deepseek.com/v1/chat/completions';
824 $headers = [
825 'Content-Type: application/json',
826 'Authorization: Bearer ' . $api_key
827 ];
828 $body = [
829 'model' => $model,
830 'messages' => [['role' => 'user', 'content' => $test_message]],
831 'max_tokens' => 50,
832 'temperature' => 0.3,
833 'stream' => true,
834 // DeepSeek V4 defaults to thinking mode ON — reasoning would
835 // consume the 50-token test budget and return no visible text.
836 'thinking' => ['type' => 'disabled']
837 ];
838 break;
839
840 default:
841 echo "data: " . json_encode(['error' => 'Unsupported provider for streaming test: ' . $provider]) . "\n\n";
842 flush();
843 return;
844 }
845
846 // Initialize cURL
847 $ch = curl_init();
848 curl_setopt($ch, CURLOPT_URL, $url);
849 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
850 curl_setopt($ch, CURLOPT_POST, true);
851 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
852 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
853 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
854 curl_setopt($ch, CURLOPT_TIMEOUT, 30);
855 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use ($provider) {
856 return $this->process_streaming_test_data($data, $provider);
857 });
858
859 // Send initial test message
860 echo "data: " . json_encode(['content' => '[Starting streaming test...]']) . "\n\n";
861 flush();
862
863 $result = curl_exec($ch);
864 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
865 $curl_error = curl_error($ch);
866 curl_close($ch);
867
868 if ($curl_error) {
869 echo "data: " . json_encode(['error' => 'cURL Error: ' . $curl_error]) . "\n\n";
870 flush();
871 return;
872 }
873
874 if ($http_code !== 200) {
875 echo "data: " . json_encode(['error' => 'API returned HTTP ' . $http_code]) . "\n\n";
876 flush();
877 return;
878 }
879
880 // Send completion signal
881 echo "data: [DONE]\n\n";
882 flush();
883 }
884
885 /**
886 * Process streaming data for the test
887 */
888 private function process_streaming_test_data($data, $provider) {
889 static $chunk_count = 0;
890
891 $lines = explode("\n", $data);
892
893 foreach ($lines as $line) {
894 if (trim($line) === '') {
895 continue;
896 }
897
898 // Handle different provider formats
899 if ($provider === 'claude') {
900 // Claude uses event: and data: format
901 if (strpos($line, 'data: ') === 0) {
902 $json_str = substr($line, 6);
903 $json = json_decode($json_str, true);
904
905 if (isset($json['type']) && $json['type'] === 'content_block_delta') {
906 if (isset($json['delta']['text'])) {
907 $chunk_count++;
908 echo "data: " . json_encode([
909 'content' => $json['delta']['text'],
910 'test_chunk' => $chunk_count
911 ]) . "\n\n";
912 flush();
913 }
914 }
915 }
916 } else {
917 // OpenAI, X.AI, DeepSeek format
918 if (strpos($line, 'data: ') === 0) {
919 $json_str = substr($line, 6);
920
921 if ($json_str === '[DONE]') {
922 // Don't echo [DONE] here, let the main function handle it
923 continue;
924 }
925
926 $json = json_decode($json_str, true);
927 if (isset($json['choices'][0]['delta']['content'])) {
928 $chunk_count++;
929 echo "data: " . json_encode([
930 'content' => $json['choices'][0]['delta']['content'],
931 'test_chunk' => $chunk_count
932 ]) . "\n\n";
933 flush();
934 }
935 }
936 }
937 }
938
939 return strlen($data);
940 }
941
942 /**
943 * Updated version of your existing test method (keep this as a fallback)
944 */
945 public function mxchat_handle_test_streaming() {
946 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
947
948 // plan-mxchat-20260731-c63fb6 — the delegate below checks this too, but
949 // fail here so the alias never becomes a bypass if that call is refactored.
950 if (!current_user_can('manage_options')) {
951 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
952 }
953
954 // Use the actual streaming test instead
955 $this->mxchat_handle_test_streaming_actual();
956 }
957
958
959 public function register_pinecone_settings() {
960 register_setting(
961 'mxchat_pinecone_addon_options',
962 'mxchat_pinecone_addon_options',
963 array(
964 'type' => 'array',
965 'sanitize_callback' => array($this, 'sanitize_pinecone_settings'),
966 'default' => array(
967 'mxchat_use_pinecone' => '0',
968 'mxchat_pinecone_api_key' => '',
969 'mxchat_pinecone_host' => '',
970 'mxchat_pinecone_index' => '',
971 'mxchat_pinecone_environment' => '',
972 'mxchat_pinecone_top_k' => '50'
973 )
974 )
975 );
976 }
977
978 public function sanitize_pinecone_settings($input) {
979 $sanitized = array();
980
981 $sanitized['mxchat_use_pinecone'] = isset($input['mxchat_use_pinecone']) ? '1' : '0';
982 $sanitized['mxchat_pinecone_api_key'] = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
983 $sanitized['mxchat_pinecone_host'] = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
984 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
985 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
986
987 // d0cae1: this function REBUILDS the array, so the topK key must be
988 // carried here or any form save strips it. Clamp mirrors the read site.
989 $top_k = absint($input['mxchat_pinecone_top_k'] ?? 50);
990 $sanitized['mxchat_pinecone_top_k'] = (string) (($top_k >= 1 && $top_k <= 1000) ? $top_k : 50);
991
992 // Remove https:// from host if present
993 $sanitized['mxchat_pinecone_host'] = str_replace(['https://', 'http://'], '', $sanitized['mxchat_pinecone_host']);
994
995 return $sanitized;
996 }
997
998 public function register_openai_vectorstore_settings() {
999 register_setting(
1000 'mxchat_openai_vectorstore_options',
1001 'mxchat_openai_vectorstore_options',
1002 array(
1003 'type' => 'array',
1004 'sanitize_callback' => array($this, 'sanitize_openai_vectorstore_settings'),
1005 'default' => array(
1006 'mxchat_use_openai_vectorstore' => '0',
1007 'mxchat_vectorstore_ids' => '',
1008 'mxchat_vectorstore_max_results' => 5,
1009 'mxchat_vectorstore_sync_enabled' => '0',
1010 'mxchat_vectorstore_sync_store_id' => ''
1011 )
1012 )
1013 );
1014 }
1015
1016 public function sanitize_openai_vectorstore_settings($input) {
1017 $sanitized = array();
1018
1019 $sanitized['mxchat_use_openai_vectorstore'] = isset($input['mxchat_use_openai_vectorstore']) ? '1' : '0';
1020 $sanitized['mxchat_vectorstore_ids'] = sanitize_text_field($input['mxchat_vectorstore_ids'] ?? '');
1021 $sanitized['mxchat_vectorstore_max_results'] = absint($input['mxchat_vectorstore_max_results'] ?? 5);
1022
1023 // Knowledge-base sync (write path, plan 15b5c6). Store ID may be blank —
1024 // the sync falls back to the first retrieval ID above.
1025 $sanitized['mxchat_vectorstore_sync_enabled'] = isset($input['mxchat_vectorstore_sync_enabled']) ? '1' : '0';
1026 $sanitized['mxchat_vectorstore_sync_store_id'] = sanitize_text_field($input['mxchat_vectorstore_sync_store_id'] ?? '');
1027
1028 // Ensure max results is within reasonable range
1029 if ($sanitized['mxchat_vectorstore_max_results'] < 1) {
1030 $sanitized['mxchat_vectorstore_max_results'] = 1;
1031 }
1032 if ($sanitized['mxchat_vectorstore_max_results'] > 20) {
1033 $sanitized['mxchat_vectorstore_max_results'] = 20;
1034 }
1035
1036 return $sanitized;
1037 }
1038
1039 /**
1040 * AJAX handler to test OpenAI Vector Store connection
1041 */
1042 public function mxchat_test_vectorstore_connection() {
1043 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1044
1045 if (!current_user_can('manage_options')) {
1046 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1047 return;
1048 }
1049
1050 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
1051 $vectorstore_ids = $vectorstore_options['mxchat_vectorstore_ids'] ?? '';
1052 $mxchat_options = get_option('mxchat_options', array());
1053 $api_key = $mxchat_options['api_key'] ?? '';
1054
1055 if (empty($api_key)) {
1056 wp_send_json_error(array('message' => __('OpenAI API key is not configured.', 'mxchat')));
1057 return;
1058 }
1059
1060 if (empty($vectorstore_ids)) {
1061 wp_send_json_error(array('message' => __('No Vector Store ID configured.', 'mxchat')));
1062 return;
1063 }
1064
1065 // Get the first Vector Store ID for testing
1066 $ids_array = array_map('trim', explode(',', $vectorstore_ids));
1067 $test_id = $ids_array[0];
1068
1069 // Test by retrieving the Vector Store info
1070 $response = wp_remote_get(
1071 'https://api.openai.com/v1/vector_stores/' . $test_id,
1072 array(
1073 'headers' => array(
1074 'Authorization' => 'Bearer ' . $api_key,
1075 'Content-Type' => 'application/json',
1076 'OpenAI-Beta' => 'assistants=v2'
1077 ),
1078 'timeout' => 30
1079 )
1080 );
1081
1082 if (is_wp_error($response)) {
1083 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $response->get_error_message()));
1084 return;
1085 }
1086
1087 $status_code = wp_remote_retrieve_response_code($response);
1088 $body = json_decode(wp_remote_retrieve_body($response), true);
1089
1090 if ($status_code === 200 && isset($body['id'])) {
1091 $file_count = $body['file_counts']['completed'] ?? 0;
1092 $name = $body['name'] ?? $test_id;
1093 wp_send_json_success(array(
1094 'message' => sprintf(
1095 __('Connected successfully! Vector Store: %s (%d files)', 'mxchat'),
1096 esc_html($name),
1097 $file_count
1098 )
1099 ));
1100 } elseif ($status_code === 404) {
1101 wp_send_json_error(array('message' => __('Vector Store not found. Please check the ID.', 'mxchat')));
1102 } elseif ($status_code === 401) {
1103 wp_send_json_error(array('message' => __('Invalid API key.', 'mxchat')));
1104 } else {
1105 $error_message = $body['error']['message'] ?? __('Unknown error occurred.', 'mxchat');
1106 wp_send_json_error(array('message' => $error_message));
1107 }
1108 }
1109
1110 /**
1111 * AJAX handler to test Slack connection and validate scopes
1112 */
1113 public function mxchat_test_slack_connection() {
1114 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1115
1116 if (!current_user_can('manage_options')) {
1117 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1118 return;
1119 }
1120
1121 $bot_token = $this->options['live_agent_bot_token'] ?? '';
1122
1123 if (empty($bot_token)) {
1124 wp_send_json_error(array('message' => __('Slack Bot Token is not configured. Please enter your token and save settings first.', 'mxchat')));
1125 return;
1126 }
1127
1128 // Test 1: Validate bot token with auth.test
1129 $auth_response = wp_remote_post('https://slack.com/api/auth.test', array(
1130 'headers' => array(
1131 'Authorization' => 'Bearer ' . $bot_token,
1132 'Content-Type' => 'application/json'
1133 ),
1134 'timeout' => 15
1135 ));
1136
1137 if (is_wp_error($auth_response)) {
1138 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $auth_response->get_error_message()));
1139 return;
1140 }
1141
1142 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
1143
1144 if (!isset($auth_body['ok']) || !$auth_body['ok']) {
1145 $error = $auth_body['error'] ?? 'unknown_error';
1146 $error_messages = array(
1147 'invalid_auth' => __('Invalid bot token. Please check your token starts with xoxb-', 'mxchat'),
1148 'not_authed' => __('No authentication token provided.', 'mxchat'),
1149 'account_inactive' => __('The Slack workspace has been deactivated.', 'mxchat'),
1150 'token_revoked' => __('The bot token has been revoked. Please generate a new one.', 'mxchat'),
1151 );
1152 $message = $error_messages[$error] ?? sprintf(__('Authentication failed: %s', 'mxchat'), $error);
1153 wp_send_json_error(array('message' => $message));
1154 return;
1155 }
1156
1157 $team_name = $auth_body['team'] ?? 'Unknown Workspace';
1158 $bot_name = $auth_body['user'] ?? 'Unknown Bot';
1159
1160 // Test 2: Check if we can list channels (tests channels:read scope)
1161 $channels_response = wp_remote_post('https://slack.com/api/conversations.list', array(
1162 'headers' => array(
1163 'Authorization' => 'Bearer ' . $bot_token,
1164 'Content-Type' => 'application/json'
1165 ),
1166 'body' => json_encode(array('limit' => 1)),
1167 'timeout' => 15
1168 ));
1169
1170 $channels_body = json_decode(wp_remote_retrieve_body($channels_response), true);
1171 $can_read_channels = isset($channels_body['ok']) && $channels_body['ok'];
1172
1173 // Test 3: Check if we can create channels (tests channels:manage scope)
1174 // We'll just check the error message without actually creating
1175 $create_response = wp_remote_post('https://slack.com/api/conversations.create', array(
1176 'headers' => array(
1177 'Authorization' => 'Bearer ' . $bot_token,
1178 'Content-Type' => 'application/json'
1179 ),
1180 'body' => json_encode(array('name' => 'mxchat-test-' . time(), 'is_private' => false)),
1181 'timeout' => 15
1182 ));
1183
1184 $create_body = json_decode(wp_remote_retrieve_body($create_response), true);
1185
1186 // If channel was created, delete it immediately
1187 if (isset($create_body['ok']) && $create_body['ok'] && isset($create_body['channel']['id'])) {
1188 wp_remote_post('https://slack.com/api/conversations.archive', array(
1189 'headers' => array(
1190 'Authorization' => 'Bearer ' . $bot_token,
1191 'Content-Type' => 'application/json'
1192 ),
1193 'body' => json_encode(array('channel' => $create_body['channel']['id'])),
1194 'timeout' => 15
1195 ));
1196 $can_create_channels = true;
1197 } else {
1198 $create_error = $create_body['error'] ?? '';
1199 // name_taken means we have permission but channel exists
1200 $can_create_channels = ($create_error === 'name_taken' || (isset($create_body['ok']) && $create_body['ok']));
1201
1202 // Check for missing scope errors
1203 if ($create_error === 'missing_scope') {
1204 $can_create_channels = false;
1205 }
1206 }
1207
1208 // Build result message
1209 $results = array();
1210 $results[] = sprintf(__('Workspace: %s', 'mxchat'), esc_html($team_name));
1211 $results[] = sprintf(__('Bot: %s', 'mxchat'), esc_html($bot_name));
1212 $results[] = '';
1213 $results[] = ($can_read_channels ? '✓' : '✗') . ' ' . __('channels:read - List channels', 'mxchat');
1214 $results[] = ($can_create_channels ? '✓' : '✗') . ' ' . __('channels:manage - Create channels', 'mxchat');
1215
1216 $missing_scopes = array();
1217 if (!$can_read_channels) $missing_scopes[] = 'channels:read';
1218 if (!$can_create_channels) $missing_scopes[] = 'channels:manage';
1219
1220 if (!empty($missing_scopes)) {
1221 wp_send_json_error(array(
1222 'message' => implode("\n", $results),
1223 'missing_scopes' => $missing_scopes,
1224 'partial' => true
1225 ));
1226 } else {
1227 wp_send_json_success(array(
1228 'message' => implode("\n", $results)
1229 ));
1230 }
1231 }
1232
1233 public function mxchat_display_admin_notice() {
1234 // Success notice
1235 if ($message = get_transient('mxchat_admin_notice_success')) {
1236 ?>
1237 <div class="notice notice-success is-dismissible">
1238 <p><?php echo esc_html($message); ?></p>
1239 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1240 </div>
1241 <?php
1242 delete_transient('mxchat_admin_notice_success'); // Clear the transient after displaying
1243 }
1244
1245 // Error notice
1246 if ($message = get_transient('mxchat_admin_notice_error')) {
1247 ?>
1248 <div class="notice notice-error is-dismissible">
1249 <p><?php echo esc_html($message); ?></p>
1250 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1251 </div>
1252 <?php
1253 delete_transient('mxchat_admin_notice_error'); // Clear the transient after displaying
1254 }
1255 }
1256
1257 public function show_live_agent_disabled_banner() {
1258 $show_disabled_notice = get_option('mxchat_show_live_agent_disabled_notice', false);
1259
1260 if ($show_disabled_notice) {
1261 ?>
1262 <div class="mxchat-live-agent-disabled-notice" id="mxchat-disabled-notice">
1263 <div class="mxchat-pro-notification">
1264 <button type="button" class="mxchat-dismiss-btn" onclick="dismissLiveAgentNotice()" aria-label="Dismiss notification">
1265 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1266 <line x1="18" y1="6" x2="6" y2="18"></line>
1267 <line x1="6" y1="6" x2="18" y2="18"></line>
1268 </svg>
1269 </button>
1270 <div class="mxchat-live-agent-content">
1271 <h3>🔧 Live Agent Integration Updated!</h3>
1272 <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>Settings &rarr; Integrations &rarr; Toolbar</strong> and reviewing the new configuration options.</p>
1273 </div>
1274 </div>
1275 </div>
1276 <?php
1277 }
1278 }
1279 /**
1280 * Render the Onboarding page. Delegates to the procedural renderer in
1281 * includes/admin-onboarding-page.php. Wired to both `?page=mxchat-max`
1282 * (legacy top-level URL) and `?page=mxchat-onboarding` (the canonical
1283 * Onboarding submenu).
1284 *
1285 * When the user has dismissed onboarding and lands on `mxchat-max` (the
1286 * legacy URL, since the Onboarding submenu has been removed), redirect to
1287 * Settings instead — the page is "graduated" and we shouldn't dump them
1288 * back onto it. They can still navigate here directly via the unhide link.
1289 */
1290 public function mxchat_create_dashboard_page() {
1291 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
1292
1293 $current = isset($_GET['page']) ? sanitize_key($_GET['page']) : '';
1294 if ($current === 'mxchat-max' && function_exists('mxchat_onboarding_is_dismissed') && mxchat_onboarding_is_dismissed()) {
1295 wp_safe_redirect(admin_url('admin.php?page=mxchat-settings'));
1296 exit;
1297 }
1298
1299 if (function_exists('mxchat_render_onboarding_page')) {
1300 mxchat_render_onboarding_page();
1301 return;
1302 }
1303 // Defensive: if the include failed to load, fall back to the old Settings page
1304 // so the top-level menu never lands on an empty screen.
1305 $this->mxchat_create_admin_page();
1306 }
1307
1308 /**
1309 * Hide the Onboarding submenu when the user has dismissed it (either
1310 * manually or via auto-graduation). The page itself remains routable so
1311 * the Settings "Show MxChat Onboarding again" link can navigate back to it.
1312 */
1313 public function mxchat_apply_onboarding_visibility() {
1314 if (!function_exists('mxchat_onboarding_is_dismissed')) {
1315 return;
1316 }
1317 if (mxchat_onboarding_is_dismissed()) {
1318 // The first MxChat child is the same-slug-as-parent registration
1319 // (slug 'mxchat-max', labelled "Onboarding") added in plan-d14e89.
1320 // Remove it so the menu opens straight to Settings after dismiss.
1321 remove_submenu_page('mxchat-max', 'mxchat-max');
1322 }
1323 }
1324
1325 public function mxchat_create_admin_page() {
1326 $this->add_live_agent_nonce();
1327 $this->add_theme_migration_nonce();
1328
1329 // Include and render the new sidebar-based settings page
1330 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
1331 mxchat_render_settings_page($this);
1332 }
1333
1334 public function dismiss_live_agent_notice() {
1335 // Add debugging
1336 //error_log('dismiss_live_agent_notice called');
1337 //error_log('POST data: ' . print_r($_POST, true));
1338
1339 // Verify nonce
1340 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_live_agent_notice')) {
1341 //error_log('Nonce verification failed');
1342 wp_die('Security check failed');
1343 }
1344
1345 // Remove the notice flag
1346 $deleted = delete_option('mxchat_show_live_agent_disabled_notice');
1347 //error_log('Option deleted: ' . ($deleted ? 'yes' : 'no'));
1348
1349 wp_send_json_success();
1350 }
1351 public function add_live_agent_nonce() {
1352 if (get_option('mxchat_show_live_agent_disabled_notice', false)) {
1353 // Make sure your admin script is enqueued and localize the data
1354 wp_localize_script('mxchat-admin-js', 'mxchatLiveAgent', array(
1355 'nonce' => wp_create_nonce('dismiss_live_agent_notice'),
1356 'ajaxurl' => admin_url('admin-ajax.php')
1357 ));
1358 }
1359 }
1360
1361 /**
1362 * Show theme migration notice for Pro users with AI-generated themes
1363 * Only shown once - dismissible and stored in options
1364 */
1365 public function show_theme_migration_banner() {
1366 // Only show if Pro is activated
1367 if (!$this->is_activated) {
1368 return;
1369 }
1370
1371 // Check if notice should be shown
1372 $show_notice = get_option('mxchat_show_theme_migration_notice', false);
1373
1374 if ($show_notice) {
1375 ?>
1376 <div class="mxchat-theme-migration-notice" id="mxchat-theme-migration-notice">
1377 <div class="mxchat-pro-notification">
1378 <button type="button" class="mxchat-dismiss-btn" onclick="dismissThemeMigrationNotice()" aria-label="Dismiss notification">
1379 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1380 <line x1="18" y1="6" x2="6" y2="18"></line>
1381 <line x1="6" y1="6" x2="18" y2="18"></line>
1382 </svg>
1383 </button>
1384 <div class="mxchat-theme-migration-content">
1385 <h3>🎨 AI Theme Migration Required</h3>
1386 <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>
1387 </div>
1388 </div>
1389 </div>
1390 <?php
1391 }
1392 }
1393
1394 /**
1395 * Dismiss theme migration notice via AJAX
1396 */
1397 public function dismiss_theme_migration_notice() {
1398 // Verify nonce
1399 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_theme_migration_notice')) {
1400 wp_die('Security check failed');
1401 }
1402
1403 // Remove the notice flag
1404 delete_option('mxchat_show_theme_migration_notice');
1405
1406 wp_send_json_success();
1407 }
1408
1409 /**
1410 * Add nonce for theme migration notice dismiss
1411 */
1412 public function add_theme_migration_nonce() {
1413 if (get_option('mxchat_show_theme_migration_notice', false) && $this->is_activated) {
1414 wp_localize_script('mxchat-admin-js', 'mxchatThemeMigration', array(
1415 'nonce' => wp_create_nonce('dismiss_theme_migration_notice'),
1416 'ajaxurl' => admin_url('admin-ajax.php')
1417 ));
1418 }
1419 }
1420
1421 public function mxchat_create_transcripts_page() {
1422 global $wpdb;
1423 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1424
1425 // Get basic stats
1426 $total_chats = $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM $table_name") ?: 0;
1427 $total_messages = $wpdb->get_var("SELECT COUNT(*) FROM $table_name") ?: 0;
1428
1429 // Count unique users with detailed breakdown
1430 $total_users = $wpdb->get_var("
1431 SELECT COUNT(DISTINCT
1432 CASE
1433 WHEN user_email != '' AND user_email IS NOT NULL THEN user_email
1434 WHEN user_id != 0 THEN CONCAT('user_', user_id)
1435 WHEN user_identifier NOT LIKE 'Tech-Savvy User'
1436 AND user_identifier NOT LIKE 'Detail-Oriented User'
1437 AND user_identifier NOT LIKE 'Language Learner'
1438 AND user_identifier NOT LIKE 'Casual Browser'
1439 AND user_identifier NOT LIKE 'Policy Enforcer'
1440 AND user_identifier NOT LIKE 'Researcher'
1441 AND user_identifier NOT LIKE 'Loyalty Member'
1442 AND user_identifier NOT LIKE 'Gift Buyer'
1443 AND user_identifier NOT LIKE 'Parent or Caregiver'
1444 THEN user_identifier
1445 ELSE session_id
1446 END
1447 )
1448 FROM $table_name
1449 WHERE role != 'assistant'
1450 ");
1451
1452 // Get user type breakdown
1453 $registered_users = $wpdb->get_var("
1454 SELECT COUNT(DISTINCT user_email)
1455 FROM $table_name
1456 WHERE user_email != '' AND user_email IS NOT NULL
1457 ");
1458
1459 $guest_users = $wpdb->get_var("
1460 SELECT COUNT(DISTINCT user_identifier)
1461 FROM $table_name
1462 WHERE (user_email = '' OR user_email IS NULL)
1463 AND role != 'assistant'
1464 AND user_identifier NOT LIKE 'Tech-Savvy User'
1465 AND user_identifier NOT LIKE 'Detail-Oriented User'
1466 AND user_identifier NOT LIKE 'Language Learner'
1467 AND user_identifier NOT LIKE 'Casual Browser'
1468 AND user_identifier NOT LIKE 'Policy Enforcer'
1469 AND user_identifier NOT LIKE 'Researcher'
1470 AND user_identifier NOT LIKE 'Loyalty Member'
1471 AND user_identifier NOT LIKE 'Gift Buyer'
1472 AND user_identifier NOT LIKE 'Parent or Caregiver'
1473 ");
1474
1475 // Get agent test messages count
1476 $agent_tests = $wpdb->get_var("
1477 SELECT COUNT(DISTINCT session_id)
1478 FROM $table_name
1479 WHERE user_identifier IN (
1480 'Tech-Savvy User',
1481 'Detail-Oriented User',
1482 'Language Learner',
1483 'Casual Browser',
1484 'Policy Enforcer',
1485 'Researcher',
1486 'Loyalty Member',
1487 'Gift Buyer',
1488 'Parent or Caregiver'
1489 )
1490 ");
1491 // Get activity metrics
1492 $today_chats = $wpdb->get_var("
1493 SELECT COUNT(DISTINCT session_id)
1494 FROM $table_name
1495 WHERE DATE(timestamp) = CURDATE()
1496 ");
1497
1498 $week_chats = $wpdb->get_var("
1499 SELECT COUNT(DISTINCT session_id)
1500 FROM $table_name
1501 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1502 ");
1503
1504 $month_chats = $wpdb->get_var("
1505 SELECT COUNT(DISTINCT session_id)
1506 FROM $table_name
1507 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
1508 ");
1509
1510 // Get daily chat data for last 7 days
1511 $daily_stats = $wpdb->get_results("
1512 SELECT
1513 DATE(timestamp) as date,
1514 COUNT(DISTINCT session_id) as chats,
1515 COUNT(*) as messages
1516 FROM $table_name
1517 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1518 GROUP BY DATE(timestamp)
1519 ORDER BY date ASC
1520 ");
1521
1522 // Get average messages per chat
1523 $avg_messages = $wpdb->get_var("
1524 SELECT AVG(message_count)
1525 FROM (
1526 SELECT session_id, COUNT(*) as message_count
1527 FROM $table_name
1528 GROUP BY session_id
1529 ) as chat_counts
1530 ");
1531 $avg_messages = $avg_messages ? round($avg_messages, 1) : 0;
1532
1533 // Get busiest hour
1534 $busiest_hour = $wpdb->get_row("
1535 SELECT HOUR(timestamp) as hour, COUNT(DISTINCT session_id) as chat_count
1536 FROM $table_name
1537 GROUP BY HOUR(timestamp)
1538 ORDER BY chat_count DESC
1539 LIMIT 1
1540 ");
1541
1542 // Prepare chart data
1543 $chart_labels = array();
1544 $chart_chats = array();
1545 $chart_messages = array();
1546
1547 // Fill last 7 days with data
1548 for ($i = 6; $i >= 0; $i--) {
1549 $date = date('Y-m-d', strtotime("-$i days"));
1550 $day_name = date('D', strtotime("-$i days"));
1551 $chart_labels[] = $day_name;
1552
1553 $found = false;
1554 foreach ($daily_stats as $stat) {
1555 if ($stat->date === $date) {
1556 $chart_chats[] = (int)$stat->chats;
1557 $chart_messages[] = (int)$stat->messages;
1558 $found = true;
1559 break;
1560 }
1561 }
1562 if (!$found) {
1563 $chart_chats[] = 0;
1564 $chart_messages[] = 0;
1565 }
1566 }
1567
1568 // Satisfaction rating rollup — last 30 days, grouped by bot (plan-a5b006).
1569 $satisfaction_stats = $this->get_satisfaction_rating_stats(30);
1570
1571 // Prepare page data for the template
1572 $page_data = array(
1573 'total_chats' => $total_chats,
1574 'total_messages' => $total_messages,
1575 'total_users' => $total_users,
1576 'registered_users' => $registered_users,
1577 'guest_users' => $guest_users,
1578 'agent_tests' => $agent_tests,
1579 'today_chats' => $today_chats,
1580 'week_chats' => $week_chats,
1581 'month_chats' => $month_chats,
1582 'avg_messages' => $avg_messages,
1583 'busiest_hour' => $busiest_hour,
1584 'chart_labels' => $chart_labels,
1585 'chart_chats' => $chart_chats,
1586 'chart_messages' => $chart_messages,
1587 'satisfaction_stats' => $satisfaction_stats,
1588 );
1589
1590 // Include and render the new template
1591 require_once plugin_dir_path(__FILE__) . 'admin-transcripts-page.php';
1592 mxchat_render_transcripts_page($this, $page_data);
1593 }
1594
1595 /**
1596 * Per-bot satisfaction rating rollup over the last $days days. Used by the
1597 * Satisfaction card on the Transcripts dashboard (plan-a5b006).
1598 *
1599 * @param int $days Window in days.
1600 * @return array Each entry: ['bot_id', 'total', 'positive', 'negative', 'positive_pct', 'negative_pct'].
1601 */
1602 public function get_satisfaction_rating_stats($days = 30) {
1603 global $wpdb;
1604 $table = $wpdb->prefix . 'mxchat_session_ratings';
1605 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
1606 return array();
1607 }
1608 $days = max(1, (int) $days);
1609 $rows = $wpdb->get_results($wpdb->prepare(
1610 "SELECT bot_id,
1611 COUNT(*) AS total,
1612 SUM(CASE WHEN rating_value = 1 THEN 1 ELSE 0 END) AS positive,
1613 SUM(CASE WHEN rating_value = -1 THEN 1 ELSE 0 END) AS negative
1614 FROM {$table}
1615 WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
1616 GROUP BY bot_id
1617 ORDER BY total DESC",
1618 $days
1619 ));
1620 $out = array();
1621 foreach ((array) $rows as $row) {
1622 $total = (int) $row->total;
1623 $positive = (int) $row->positive;
1624 $negative = (int) $row->negative;
1625 $out[] = array(
1626 'bot_id' => $row->bot_id ?: 'default',
1627 'total' => $total,
1628 'positive' => $positive,
1629 'negative' => $negative,
1630 'positive_pct' => $total > 0 ? (int) round(($positive / $total) * 100) : 0,
1631 'negative_pct' => $total > 0 ? (int) round(($negative / $total) * 100) : 0,
1632 );
1633 }
1634 return $out;
1635 }
1636
1637 /**
1638 * Get chart data for transcripts page
1639 * Used by both page render and script localization
1640 *
1641 * @return array Chart data with labels, chats, and messages arrays
1642 */
1643 private function get_transcripts_chart_data() {
1644 global $wpdb;
1645 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1646
1647 // Get daily chat data for last 7 days - same query as mxchat_transcripts_page()
1648 $daily_stats = $wpdb->get_results("
1649 SELECT
1650 DATE(timestamp) as date,
1651 COUNT(DISTINCT session_id) as chats,
1652 COUNT(*) as messages
1653 FROM $table_name
1654 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1655 GROUP BY DATE(timestamp)
1656 ORDER BY date ASC
1657 ");
1658
1659 // Prepare chart data
1660 $chart_labels = array();
1661 $chart_chats = array();
1662 $chart_messages = array();
1663
1664 // Fill last 7 days with data - same logic as mxchat_transcripts_page()
1665 for ($i = 6; $i >= 0; $i--) {
1666 $date = date('Y-m-d', strtotime("-$i days"));
1667 $day_name = date('D', strtotime("-$i days"));
1668 $chart_labels[] = $day_name;
1669
1670 $found = false;
1671 if ($daily_stats) {
1672 foreach ($daily_stats as $stat) {
1673 if ($stat->date === $date) {
1674 $chart_chats[] = (int)$stat->chats;
1675 $chart_messages[] = (int)$stat->messages;
1676 $found = true;
1677 break;
1678 }
1679 }
1680 }
1681 if (!$found) {
1682 $chart_chats[] = 0;
1683 $chart_messages[] = 0;
1684 }
1685 }
1686
1687 return array(
1688 'labels' => $chart_labels,
1689 'chats' => $chart_chats,
1690 'messages' => $chart_messages
1691 );
1692 }
1693
1694 public function mxchat_transcripts_notification_section_callback() {
1695 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>';
1696 }
1697 public function mxchat_enable_notifications_callback() {
1698 $options = get_option('mxchat_transcripts_options', array());
1699 $enabled = isset($options['mxchat_enable_notifications']) ? $options['mxchat_enable_notifications'] : 0;
1700 ?>
1701 <label for="mxchat_enable_notifications">
1702 <input type="checkbox" id="mxchat_enable_notifications"
1703 name="mxchat_transcripts_options[mxchat_enable_notifications]"
1704 value="1" <?php checked(1, $enabled); ?>>
1705 <?php esc_html_e('Send email notification when a new chat session starts', 'mxchat'); ?>
1706 </label>
1707 <p class="description">
1708 <?php esc_html_e('Enable this option to receive email notifications for new chat sessions.', 'mxchat'); ?>
1709 </p>
1710 <?php
1711 }
1712 public function mxchat_notification_email_callback() {
1713 $options = get_option('mxchat_transcripts_options', array());
1714 $email = isset($options['mxchat_notification_email']) ? $options['mxchat_notification_email'] : get_option('admin_email');
1715 ?>
1716 <?php /* type="text", not "email": the browser rejects a comma-separated list outright (plan 2f131a). Validation is server-side in MxChat_Utils. */ ?>
1717 <input type="text" id="mxchat_notification_email"
1718 name="mxchat_transcripts_options[mxchat_notification_email]"
1719 value="<?php echo esc_attr($email); ?>"
1720 class="regular-text">
1721 <p class="description">
1722 <?php printf(esc_html__('Enter the email address where notifications should be sent. Separate up to %d addresses with commas. Defaults to the admin email address.', 'mxchat'), (int) MxChat_Utils::NOTIFICATION_EMAIL_MAX); ?>
1723 </p>
1724 <?php
1725 }
1726
1727 public function mxchat_auto_delete_transcripts_callback() {
1728 $options = get_option('mxchat_transcripts_options', array());
1729 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1730 ?>
1731 <select id="mxchat_auto_delete_transcripts"
1732 name="mxchat_transcripts_options[mxchat_auto_delete_transcripts]">
1733 <option value="never" <?php selected($interval, 'never'); ?>>
1734 <?php esc_html_e('Never (Keep All Transcripts)', 'mxchat'); ?>
1735 </option>
1736 <option value="1week" <?php selected($interval, '1week'); ?>>
1737 <?php esc_html_e('After 1 Week', 'mxchat'); ?>
1738 </option>
1739 <option value="2weeks" <?php selected($interval, '2weeks'); ?>>
1740 <?php esc_html_e('After 2 Weeks', 'mxchat'); ?>
1741 </option>
1742 <option value="1month" <?php selected($interval, '1month'); ?>>
1743 <?php esc_html_e('After 1 Month', 'mxchat'); ?>
1744 </option>
1745 </select>
1746 <p class="description">
1747 <?php esc_html_e('Automatically delete old chat transcripts after the selected time period. This helps manage database size and privacy.', 'mxchat'); ?>
1748 </p>
1749 <?php
1750 }
1751
1752 /**
1753 * Custom retention-days input. When > 0 it overrides the bucket dropdown above
1754 * and deletes transcripts older than the given number of days. Set to 0 to fall
1755 * back to the dropdown (or "Never" if the dropdown is also Never).
1756 *
1757 * Devs can override the final day count via the `mxchat_transcript_retention_days`
1758 * filter — runs in `cleanup_old_transcripts()` after this option is read.
1759 *
1760 * (plan-mxchat-20260509-9b80b1)
1761 */
1762 public function mxchat_retention_days_callback() {
1763 $options = get_option('mxchat_transcripts_options', array());
1764 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1765 ?>
1766 <input type="number"
1767 id="mxchat_retention_days"
1768 name="mxchat_transcripts_options[mxchat_retention_days]"
1769 value="<?php echo esc_attr($days); ?>"
1770 min="0"
1771 max="3650"
1772 step="1"
1773 style="width: 90px;" />
1774 <p class="description">
1775 <?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'); ?>
1776 <br>
1777 <code>apply_filters( 'mxchat_transcript_retention_days', $days )</code>
1778 <?php esc_html_e('lets developers override the final day count programmatically.', 'mxchat'); ?>
1779 </p>
1780 <?php
1781 }
1782
1783 public function mxchat_auto_email_transcript_callback() {
1784 $options = get_option('mxchat_transcripts_options', array());
1785 $enabled = isset($options['mxchat_auto_email_transcript_enabled']) ? $options['mxchat_auto_email_transcript_enabled'] : 0;
1786 $delay = isset($options['mxchat_auto_email_transcript_delay']) ? $options['mxchat_auto_email_transcript_delay'] : '30';
1787 $require_contact = isset($options['mxchat_auto_email_transcript_require_contact']) ? $options['mxchat_auto_email_transcript_require_contact'] : 0;
1788 ?>
1789 <label>
1790 <input type="checkbox"
1791 id="mxchat_auto_email_transcript_enabled"
1792 name="mxchat_transcripts_options[mxchat_auto_email_transcript_enabled]"
1793 value="1"
1794 <?php checked($enabled, 1); ?>>
1795 <?php esc_html_e('Enable Auto-Email of Full Transcript', 'mxchat'); ?>
1796 </label>
1797 <br><br>
1798 <label for="mxchat_auto_email_transcript_delay">
1799 <?php esc_html_e('Send transcript after:', 'mxchat'); ?>
1800 </label>
1801 <select id="mxchat_auto_email_transcript_delay"
1802 name="mxchat_transcripts_options[mxchat_auto_email_transcript_delay]">
1803 <option value="15" <?php selected($delay, '15'); ?>>
1804 <?php esc_html_e('15 minutes', 'mxchat'); ?>
1805 </option>
1806 <option value="30" <?php selected($delay, '30'); ?>>
1807 <?php esc_html_e('30 minutes', 'mxchat'); ?>
1808 </option>
1809 <option value="60" <?php selected($delay, '60'); ?>>
1810 <?php esc_html_e('1 hour', 'mxchat'); ?>
1811 </option>
1812 </select>
1813 <br><br>
1814 <label>
1815 <input type="checkbox"
1816 id="mxchat_auto_email_transcript_require_contact"
1817 name="mxchat_transcripts_options[mxchat_auto_email_transcript_require_contact]"
1818 value="1"
1819 <?php checked($require_contact, 1); ?>>
1820 <?php esc_html_e('Only send if visitor provided contact info', 'mxchat'); ?>
1821 </label>
1822 <p class="description">
1823 <?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'); ?>
1824 <br>
1825 <?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'); ?>
1826 </p>
1827 <?php
1828 }
1829
1830
1831
1832 public function sanitize_transcripts_options($input) {
1833 $sanitized = array();
1834
1835 $sanitized['mxchat_enable_notifications'] = isset($input['mxchat_enable_notifications']) ? 1 : 0;
1836
1837 if (isset($input['mxchat_notification_email'])) {
1838 // Same list rule as the Transcripts autosave path (plan 2f131a). These two
1839 // paths disagreeing is how the original bug survived: this one DID call
1840 // is_email(), but on sanitize_email()'s output — which is valid even when
1841 // the input was two addresses mashed into one. See MxChat_Utils.
1842 $parsed = MxChat_Utils::parse_notification_emails($input['mxchat_notification_email']);
1843 if ($parsed['error'] !== '') {
1844 add_settings_error(
1845 'mxchat_transcripts_options',
1846 'invalid_email',
1847 $parsed['error'],
1848 'error'
1849 );
1850 // Keep whatever was already stored. Do NOT fall back to admin_email:
1851 // that fallback belongs to a genuinely EMPTY field, and using it here
1852 // would quietly redirect notifications away from the address on screen.
1853 $existing = get_option('mxchat_transcripts_options', array());
1854 if (is_array($existing) && isset($existing['mxchat_notification_email'])) {
1855 $sanitized['mxchat_notification_email'] = $existing['mxchat_notification_email'];
1856 }
1857 } else {
1858 $sanitized['mxchat_notification_email'] = implode(', ', $parsed['emails']);
1859 }
1860 }
1861
1862 // Sanitize auto-delete setting
1863 $valid_intervals = array('never', '1week', '2weeks', '1month');
1864 if (isset($input['mxchat_auto_delete_transcripts'])) {
1865 $sanitized['mxchat_auto_delete_transcripts'] = in_array($input['mxchat_auto_delete_transcripts'], $valid_intervals)
1866 ? $input['mxchat_auto_delete_transcripts']
1867 : 'never';
1868 } else {
1869 $sanitized['mxchat_auto_delete_transcripts'] = 'never';
1870 }
1871
1872 // Sanitize custom retention-days override (plan-9b80b1).
1873 if (isset($input['mxchat_retention_days'])) {
1874 $days = (int) $input['mxchat_retention_days'];
1875 $sanitized['mxchat_retention_days'] = max(0, min(3650, $days));
1876 } else {
1877 // Absent key = a partial save (WP-CLI patch, a snippet, any
1878 // update_option with a subset — this filter runs on ALL of them).
1879 // Preserve the stored value instead of zeroing it: resetting here
1880 // silently discarded a hand-set retention on the next unrelated
1881 // save of this option (plan-3c3338). The page's own autosave path
1882 // bypasses this filter entirely (direct $wpdb write), so this
1883 // branch only ever sees programmatic saves.
1884 $mxch_prev = get_option('mxchat_transcripts_options', array());
1885 $sanitized['mxchat_retention_days'] = (is_array($mxch_prev) && isset($mxch_prev['mxchat_retention_days']))
1886 ? max(0, min(3650, (int) $mxch_prev['mxchat_retention_days']))
1887 : 0;
1888 }
1889
1890 // Get old values to check if auto-delete or retention-days changed.
1891 $old_options = get_option('mxchat_transcripts_options');
1892 $old_interval = isset($old_options['mxchat_auto_delete_transcripts']) ? $old_options['mxchat_auto_delete_transcripts'] : 'never';
1893 $old_retention = isset($old_options['mxchat_retention_days']) ? (int) $old_options['mxchat_retention_days'] : 0;
1894
1895 // If either setting changed, reschedule the cron job. The schedule_transcript_cleanup
1896 // helper now treats "any active retention" (dropdown != never OR custom days > 0) as a
1897 // reason to keep the daily cron registered.
1898 $interval_changed = ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']);
1899 $retention_changed = ($old_retention !== $sanitized['mxchat_retention_days']);
1900 if ($interval_changed || $retention_changed) {
1901 $any_active = ($sanitized['mxchat_auto_delete_transcripts'] !== 'never') || ($sanitized['mxchat_retention_days'] > 0);
1902 $this->schedule_transcript_cleanup($any_active ? 'active' : 'never');
1903 }
1904
1905 // Sanitize auto-email transcript settings
1906 $sanitized['mxchat_auto_email_transcript_enabled'] = isset($input['mxchat_auto_email_transcript_enabled']) ? 1 : 0;
1907
1908 $valid_delays = array('15', '30', '60');
1909 if (isset($input['mxchat_auto_email_transcript_delay'])) {
1910 $sanitized['mxchat_auto_email_transcript_delay'] = in_array($input['mxchat_auto_email_transcript_delay'], $valid_delays)
1911 ? $input['mxchat_auto_email_transcript_delay']
1912 : '30';
1913 } else {
1914 $sanitized['mxchat_auto_email_transcript_delay'] = '30';
1915 }
1916
1917 // Sanitize require contact info setting
1918 $sanitized['mxchat_auto_email_transcript_require_contact'] = isset($input['mxchat_auto_email_transcript_require_contact']) ? 1 : 0;
1919
1920 return $sanitized;
1921 }
1922
1923 /**
1924 * Schedule or unschedule the transcript cleanup cron job
1925 */
1926 public function schedule_transcript_cleanup($interval) {
1927 // Clear any existing scheduled event
1928 $timestamp = wp_next_scheduled('mxchat_cleanup_old_transcripts');
1929 if ($timestamp) {
1930 wp_unschedule_event($timestamp, 'mxchat_cleanup_old_transcripts');
1931 }
1932
1933 // Schedule new event whenever retention is active. "active" is the canonical
1934 // value passed by sanitize_transcripts_options when either the dropdown != never
1935 // OR the custom retention-days > 0; "never" turns the cron off. Any other value
1936 // (the legacy "1week" / "2weeks" / "1month" strings) is also treated as active.
1937 if ($interval !== 'never') {
1938 // Schedule to run daily at 3 AM
1939 $next_run = strtotime('tomorrow 3:00 AM');
1940 wp_schedule_event($next_run, 'daily', 'mxchat_cleanup_old_transcripts');
1941 }
1942 }
1943
1944 /**
1945 * Self-heal guard: keep the cleanup cron in sync with the retention setting.
1946 *
1947 * Runs on admin_init. Cost when nothing is wrong: one get_option() (cached) and
1948 * one wp_next_scheduled() (reads the cached cron option) — no writes. It only
1949 * schedules when retention is active but the event is missing, and only
1950 * unschedules when retention is off but the event survived. Both directions
1951 * reuse schedule_transcript_cleanup() so there is exactly one scheduling path.
1952 * Legacy dropdown values (1week/2weeks/1month) count as active, matching the
1953 * helper's own semantics. Deliberately not gated on DISABLE_WP_CRON: scheduling
1954 * writes the cron option regardless of how cron is executed, so a server-side
1955 * cron runner still picks the event up.
1956 */
1957 public function ensure_transcript_cleanup_scheduled() {
1958 $options = get_option('mxchat_transcripts_options', array());
1959 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1960 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1961 $active = ($interval !== 'never') || ($days > 0);
1962 $scheduled = (bool) wp_next_scheduled('mxchat_cleanup_old_transcripts');
1963
1964 if ($active && !$scheduled) {
1965 $this->schedule_transcript_cleanup('active');
1966 } elseif (!$active && $scheduled) {
1967 $this->schedule_transcript_cleanup('never');
1968 }
1969 }
1970
1971 /**
1972 * Delete old transcripts based on the configured interval
1973 */
1974 public function cleanup_old_transcripts() {
1975 $options = get_option('mxchat_transcripts_options', array());
1976 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1977 $custom_days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1978
1979 // Custom retention-days (plan-9b80b1) takes precedence over the bucket dropdown.
1980 $days = 0;
1981 if ($custom_days > 0) {
1982 $days = $custom_days;
1983 } else {
1984 switch ($interval) {
1985 case '1week': $days = 7; break;
1986 case '2weeks': $days = 14; break;
1987 case '1month': $days = 30; break;
1988 case 'never':
1989 default:
1990 $days = 0;
1991 }
1992 }
1993
1994 // Devs can override the final day count programmatically.
1995 $days = (int) apply_filters('mxchat_transcript_retention_days', $days);
1996
1997 if ($days <= 0) {
1998 return; // Retention disabled — bail.
1999 }
2000
2001 global $wpdb;
2002 $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
2003 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2004 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
2005
2006 // Defensive: if the main table doesn't exist (fresh-ish install), bail.
2007 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
2008 return;
2009 }
2010
2011 $cutoff_date = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
2012
2013 // Cap at 5000 session_ids per run so a large unattended site doesn't OOM —
2014 // the cron will pick up where it left off on the next tick (within hours).
2015 $batch_cap = (int) apply_filters('mxchat_transcript_retention_batch_cap', 5000);
2016
2017 $sessions_to_delete = $wpdb->get_col(
2018 $wpdb->prepare(
2019 "SELECT DISTINCT session_id FROM {$transcripts_table} WHERE timestamp IS NOT NULL AND timestamp < %s LIMIT %d",
2020 $cutoff_date,
2021 $batch_cap
2022 )
2023 );
2024
2025 if (empty($sessions_to_delete)) {
2026 return;
2027 }
2028
2029 $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s'));
2030
2031 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2032 // $placeholders is a server-built list of literal "%s" tokens.
2033 $deleted_transcripts = (int) $wpdb->query(
2034 $wpdb->prepare(
2035 "DELETE FROM {$transcripts_table} WHERE session_id IN ($placeholders)",
2036 $sessions_to_delete
2037 )
2038 );
2039
2040 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
2041 $wpdb->query(
2042 $wpdb->prepare(
2043 "DELETE FROM {$translations_table} WHERE session_id IN ($placeholders)",
2044 $sessions_to_delete
2045 )
2046 );
2047 }
2048
2049 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
2050 $wpdb->query(
2051 $wpdb->prepare(
2052 "DELETE FROM {$url_clicks_table} WHERE session_id IN ($placeholders)",
2053 $sessions_to_delete
2054 )
2055 );
2056 }
2057 // phpcs:enable
2058
2059 update_option('mxchat_retention_last_swept_at', time(), false);
2060 update_option('mxchat_retention_rows_last_deleted', $deleted_transcripts, false);
2061 }
2062
2063 public function export_chat_transcripts() {
2064 if (!current_user_can('manage_options')) {
2065 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2066 }
2067
2068 check_ajax_referer('mxchat_export_transcripts', 'security');
2069
2070 global $wpdb;
2071 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
2072
2073 // Get all transcripts ordered by session and timestamp
2074 $results = $wpdb->get_results(
2075 "SELECT session_id, user_email, user_identifier, role, message, timestamp
2076 FROM {$table_name}
2077 ORDER BY session_id, timestamp ASC"
2078 );
2079
2080 if (empty($results)) {
2081 wp_send_json_error(array('message' => 'No transcripts found.'));
2082 wp_die();
2083 }
2084
2085 // Set headers for CSV download
2086 header('Content-Type: text/csv');
2087 header('Content-Disposition: attachment; filename="chat-transcripts-' . date('Y-m-d') . '.csv"');
2088 header('Pragma: no-cache');
2089 header('Expires: 0');
2090
2091 // Create output stream
2092 $output = fopen('php://output', 'w');
2093
2094 // Add UTF-8 BOM for proper Excel encoding
2095 fputs($output, "\xEF\xBB\xBF");
2096
2097 // Add CSV headers
2098 fputcsv($output, array(
2099 'Session ID',
2100 'Email',
2101 'User Identifier',
2102 'Role',
2103 'Message',
2104 'Timestamp'
2105 ));
2106
2107 // Add data rows
2108 foreach ($results as $row) {
2109 fputcsv($output, array(
2110 $row->session_id,
2111 $row->user_email,
2112 $row->user_identifier,
2113 $row->role,
2114 $row->message,
2115 $row->timestamp
2116 ));
2117 }
2118
2119 fclose($output);
2120 wp_die();
2121 }
2122
2123 // ============================================================================
2124 // Leads tab (inside Transcripts)
2125 //
2126 // Leads are derived from existing data — no dedicated table. Primary source:
2127 // wp_mxchat_chat_transcripts rows where user_email is populated. Secondary
2128 // source: the mxchat_sessions table's visitor_email/visitor_name columns
2129 // (plus any unmigrated legacy `mxchat_email_{sid}` wp_options rows) for
2130 // "orphan" leads who submitted the pre-chat form but never chatted.
2131 // ============================================================================
2132
2133 /**
2134 * Fetch leads: dedup-by-email rows, stats strip, and top pages in one call.
2135 */
2136 public function mxchat_fetch_leads() {
2137 if (!current_user_can('manage_options')) {
2138 wp_send_json_error(['message' => 'Insufficient permissions']);
2139 wp_die();
2140 }
2141
2142 global $wpdb;
2143 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2144
2145 $page = isset($_POST['page']) ? max(1, absint($_POST['page'])) : 1;
2146 $per_page = isset($_POST['per_page']) ? min(100, max(10, absint($_POST['per_page']))) : 25;
2147 $offset = ($page - 1) * $per_page;
2148 $search = isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '';
2149 $date_range = isset($_POST['date_range']) ? sanitize_key($_POST['date_range']) : 'all';
2150 $status = isset($_POST['status']) ? sanitize_key($_POST['status']) : 'all';
2151 $page_filter = isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '';
2152 $sort = isset($_POST['sort']) ? sanitize_key($_POST['sort']) : 'last_seen';
2153 $sort_dir = (isset($_POST['sort_dir']) && $_POST['sort_dir'] === 'asc') ? 'ASC' : 'DESC';
2154
2155 $date_cutoff = self::mxchat_leads_date_cutoff($date_range);
2156 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2157
2158 // Base WHERE for transcripts leads.
2159 $where_clauses = ["user_email IS NOT NULL", "user_email != ''"];
2160 $where_params = [];
2161
2162 if ($date_cutoff) {
2163 $where_clauses[] = 'timestamp >= %s';
2164 $where_params[] = $date_cutoff;
2165 }
2166 if ($page_filter && $has_page_url_column) {
2167 $where_clauses[] = 'originating_page_url = %s';
2168 $where_params[] = $page_filter;
2169 }
2170 if ($search !== '') {
2171 $like = '%' . $wpdb->esc_like($search) . '%';
2172 $where_clauses[] = '(user_email LIKE %s OR user_name LIKE %s)';
2173 $where_params[] = $like;
2174 $where_params[] = $like;
2175 }
2176 $where_sql = 'WHERE ' . implode(' AND ', $where_clauses);
2177
2178 // Aggregate query grouped by email.
2179 $select_sql = $has_page_url_column
2180 ? "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2181 COUNT(DISTINCT session_id) AS conversation_count"
2182 : "SELECT user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2183 COUNT(DISTINCT session_id) AS conversation_count";
2184
2185 $order_column = in_array($sort, ['last_seen', 'conversation_count', 'first_seen'], true) ? $sort : 'last_seen';
2186 $group_order_limit = " GROUP BY user_email ORDER BY {$order_column} {$sort_dir} LIMIT %d OFFSET %d";
2187
2188 $transcripts_sql = $wpdb->prepare(
2189 "{$select_sql} FROM {$table} {$where_sql}{$group_order_limit}",
2190 array_merge($where_params, [$per_page, $offset])
2191 );
2192 $transcript_rows = $wpdb->get_results($transcripts_sql);
2193
2194 // Count of unique transcript-based leads under the same filters.
2195 $count_sql = $wpdb->prepare(
2196 "SELECT COUNT(DISTINCT user_email) FROM {$table} {$where_sql}",
2197 $where_params
2198 );
2199 $transcripts_lead_count = (int) $wpdb->get_var($count_sql);
2200
2201 // Hydrate each row: name, latest_session_id, top page.
2202 $leads = [];
2203 foreach ($transcript_rows as $row) {
2204 $detail = $has_page_url_column
2205 ? $wpdb->get_row($wpdb->prepare(
2206 "SELECT session_id, user_name, originating_page_url, originating_page_title
2207 FROM {$table} WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2208 $row->user_email
2209 ))
2210 : $wpdb->get_row($wpdb->prepare(
2211 "SELECT session_id, user_name FROM {$table}
2212 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2213 $row->user_email
2214 ));
2215
2216 $leads[] = [
2217 'email' => $row->user_email,
2218 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2219 'conversation_count' => (int) $row->conversation_count,
2220 'last_seen' => $row->last_seen,
2221 'last_seen_display' => self::mxchat_leads_format_relative($row->last_seen),
2222 'first_seen' => $row->first_seen,
2223 'latest_session_id' => isset($detail->session_id) ? $detail->session_id : '',
2224 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2225 'top_page_title' => isset($detail->originating_page_title) ? $detail->originating_page_title : '',
2226 'is_orphan' => false,
2227 'status' => 'active',
2228 ];
2229 }
2230
2231 // Non-transcript lead sources. Built once here, then filtered/merged based on the
2232 // status filter below. Deduplication priority when the same email appears in multiple
2233 // sources: transcripts > chat_deleted > orphan.
2234 $transcripts_emails_seen = array_flip(array_map(
2235 function ($r) { return strtolower($r['email']); },
2236 $leads
2237 ));
2238
2239 // Chat-deleted leads: had a conversation that an admin removed. Preserved via
2240 // mxchat_lead_del_* options, with timestamps so they respect date filters.
2241 $chat_deleted_leads_all = [];
2242 if ($status === 'all' || $status === 'chat_deleted') {
2243 $chat_deleted_leads_all = self::mxchat_collect_chat_deleted_leads($search, $date_cutoff);
2244 // Dedup: drop any chat_deleted row whose email is already in the transcripts set.
2245 $chat_deleted_leads_all = array_values(array_filter(
2246 $chat_deleted_leads_all,
2247 function ($row) use ($transcripts_emails_seen) {
2248 return !isset($transcripts_emails_seen[strtolower($row['email'])]);
2249 }
2250 ));
2251 foreach ($chat_deleted_leads_all as $row) {
2252 $transcripts_emails_seen[strtolower($row['email'])] = true;
2253 }
2254 }
2255
2256 // Orphans: pre-chat form captures with no conversation. No timestamp, so skipped
2257 // when a date filter is active.
2258 $orphan_leads_all = [];
2259 if (($status === 'all' || $status === 'orphan') && !$date_cutoff && !$page_filter) {
2260 $orphan_leads_all = self::mxchat_collect_orphan_leads($search);
2261 $orphan_leads_all = array_values(array_filter(
2262 $orphan_leads_all,
2263 function ($row) use ($transcripts_emails_seen) {
2264 return !isset($transcripts_emails_seen[strtolower($row['email'])]);
2265 }
2266 ));
2267 }
2268
2269 // Apply status filter to the transcripts-derived list.
2270 if ($status === 'orphan' || $status === 'chat_deleted') {
2271 $leads = [];
2272 $transcripts_lead_count = 0;
2273 }
2274
2275 // Stitch the current page from the three buckets in priority order.
2276 $total_count = $transcripts_lead_count + count($chat_deleted_leads_all) + count($orphan_leads_all);
2277 $remaining_slots = $per_page - count($leads);
2278
2279 if ($remaining_slots > 0 && !empty($chat_deleted_leads_all)) {
2280 $start = max(0, ($page - 1) * $per_page - $transcripts_lead_count);
2281 if ($start < count($chat_deleted_leads_all)) {
2282 $leads = array_merge($leads, array_slice($chat_deleted_leads_all, $start, $remaining_slots));
2283 $remaining_slots = $per_page - count($leads);
2284 }
2285 }
2286
2287 if ($remaining_slots > 0 && !empty($orphan_leads_all)) {
2288 $before = $transcripts_lead_count + count($chat_deleted_leads_all);
2289 $start = max(0, ($page - 1) * $per_page - $before);
2290 if ($start < count($orphan_leads_all)) {
2291 $leads = array_merge($leads, array_slice($orphan_leads_all, $start, $remaining_slots));
2292 }
2293 }
2294
2295 $total_pages = $per_page > 0 ? (int) ceil($total_count / $per_page) : 1;
2296
2297 // Stats strip: always computed over full dataset, unaffected by filters.
2298 $stats = self::mxchat_leads_stats($table, $has_page_url_column);
2299
2300 // Top pages: top 5 by distinct emails captured.
2301 $top_pages = [];
2302 if ($has_page_url_column) {
2303 $top_pages_rows = $wpdb->get_results(
2304 "SELECT originating_page_url AS url,
2305 MAX(originating_page_title) AS title,
2306 COUNT(DISTINCT user_email) AS lead_count
2307 FROM {$table}
2308 WHERE user_email IS NOT NULL AND user_email != ''
2309 AND originating_page_url IS NOT NULL AND originating_page_url != ''
2310 GROUP BY originating_page_url
2311 ORDER BY lead_count DESC, url ASC
2312 LIMIT 5"
2313 );
2314 foreach ($top_pages_rows as $p) {
2315 $top_pages[] = [
2316 'url' => $p->url,
2317 'title' => $p->title ?: $p->url,
2318 'lead_count' => (int) $p->lead_count,
2319 ];
2320 }
2321 }
2322
2323 // Consent state per lead (b062c4). Chat-deleted rows already carry it from
2324 // their preserved options; the rest read the newest session-store record
2325 // for that email. Empty string = NOT RECORDED (pre-feature capture or the
2326 // checkbox was off) — distinct from 'no', which is a recorded decline.
2327 $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent');
2328 foreach ($leads as &$lead_consent_ref) {
2329 if (array_key_exists('consent', $lead_consent_ref)) {
2330 continue;
2331 }
2332 $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($lead_consent_ref['email']) : null;
2333 $lead_consent_ref['consent'] = $consent ? $consent['given'] : '';
2334 $lead_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : '';
2335 $lead_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : '';
2336 }
2337 unset($lead_consent_ref);
2338
2339 wp_send_json([
2340 'success' => true,
2341 'leads' => $leads,
2342 'page' => $page,
2343 'per_page' => $per_page,
2344 'total_count' => $total_count,
2345 'total_pages' => $total_pages,
2346 'showing_start' => $total_count === 0 ? 0 : ($offset + 1),
2347 'showing_end' => min($offset + $per_page, $total_count),
2348 'stats' => $stats,
2349 'top_pages' => $top_pages,
2350 ]);
2351 wp_die();
2352 }
2353
2354 /**
2355 * Delete one or more leads by email. Removes every transcripts row for that
2356 * email and cleans up related wp_options (mxchat_email_{sid}, mxchat_name_{sid},
2357 * mxchat_history_{sid}) and any orphan option entries matching the email.
2358 */
2359 public function mxchat_delete_leads() {
2360 if (!current_user_can('manage_options')) {
2361 wp_send_json_error(['message' => 'Insufficient permissions']);
2362 wp_die();
2363 }
2364 check_ajax_referer('mxchat_delete_leads', 'security');
2365
2366 $emails_raw = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2367 $emails = [];
2368 foreach ($emails_raw as $e) {
2369 $clean = sanitize_email((string) $e);
2370 if ($clean) {
2371 $emails[] = $clean;
2372 }
2373 }
2374 if (empty($emails)) {
2375 wp_send_json_error(['message' => 'No emails provided']);
2376 wp_die();
2377 }
2378
2379 $summary = self::mxchat_wipe_leads_by_email($emails);
2380
2381 wp_send_json([
2382 'success' => true,
2383 'deleted_leads' => count($emails),
2384 'deleted_sessions' => $summary['deleted_sessions'],
2385 'deleted_rows' => $summary['deleted_rows'],
2386 ]);
2387 wp_die();
2388 }
2389
2390 /**
2391 * Fully wipe one or more leads by email: every transcripts row, every related wp_options
2392 * entry (history, pre-chat capture, chat_deleted preservation, agent name, translations).
2393 *
2394 * Shared between the Leads-tab Delete button and the transcript-delete opt-in checkbox.
2395 * Input emails must already be sanitized with sanitize_email().
2396 */
2397 private static function mxchat_wipe_leads_by_email(array $emails) {
2398 global $wpdb;
2399 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2400 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2401 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2402
2403 $deleted_sessions = 0;
2404 $deleted_rows = 0;
2405 $emails_lc = array_map('strtolower', $emails);
2406
2407 foreach ($emails as $email) {
2408 $session_ids = $wpdb->get_col($wpdb->prepare(
2409 "SELECT DISTINCT session_id FROM {$table} WHERE user_email = %s",
2410 $email
2411 ));
2412
2413 $rows_removed = $wpdb->delete($table, ['user_email' => $email], ['%s']);
2414 if ($rows_removed !== false) {
2415 $deleted_rows += (int) $rows_removed;
2416 }
2417
2418 foreach ($session_ids as $sid) {
2419 $deleted_sessions++;
2420 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2421 delete_option('mxchat_history_' . $sid);
2422 delete_option('mxchat_email_' . $sid);
2423 delete_option('mxchat_name_' . $sid);
2424 delete_option('mxchat_agent_name_' . $sid);
2425 delete_option('mxchat_lead_del_email_' . $sid);
2426 delete_option('mxchat_lead_del_name_' . $sid);
2427 delete_option('mxchat_lead_del_ts_' . $sid);
2428 delete_option('mxchat_lead_del_consent_' . $sid);
2429 delete_option('mxchat_lead_del_consent_label_' . $sid);
2430 delete_option('mxchat_lead_del_consent_at_' . $sid);
2431 if (class_exists('MxChat_Session_Store')) {
2432 MxChat_Session_Store::delete_session($sid); // b64b77
2433 }
2434 if ($has_translations) {
2435 $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2436 }
2437 }
2438 }
2439
2440 // Table-side twin of the option sweep below (5658f2): orphan pre-chat
2441 // captures now live in the sessions table's identity columns.
2442 if (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'find_by_email')) {
2443 foreach ($emails as $email) {
2444 foreach (MxChat_Session_Store::find_by_email($email) as $sid) {
2445 MxChat_Session_Store::delete_session($sid);
2446 }
2447 }
2448 }
2449
2450 // Clean up any lingering option entries (unmigrated orphan pre-chat captures +
2451 // chat_deleted preservations) whose stored value matches one of the emails being wiped.
2452 $lingering = $wpdb->get_results(
2453 "SELECT option_name, option_value FROM {$wpdb->options}
2454 WHERE option_name LIKE 'mxchat_email_%' OR option_name LIKE 'mxchat_lead_del_email_%'"
2455 );
2456 foreach ($lingering as $opt) {
2457 if (!in_array(strtolower(trim($opt->option_value)), $emails_lc, true)) {
2458 continue;
2459 }
2460 if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
2461 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2462 delete_option('mxchat_lead_del_email_' . $sid);
2463 delete_option('mxchat_lead_del_name_' . $sid);
2464 delete_option('mxchat_lead_del_ts_' . $sid);
2465 delete_option('mxchat_lead_del_consent_' . $sid);
2466 delete_option('mxchat_lead_del_consent_label_' . $sid);
2467 delete_option('mxchat_lead_del_consent_at_' . $sid);
2468 } else {
2469 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2470 delete_option('mxchat_email_' . $sid);
2471 delete_option('mxchat_name_' . $sid);
2472 }
2473 }
2474
2475 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
2476
2477 return [
2478 'deleted_sessions' => $deleted_sessions,
2479 'deleted_rows' => $deleted_rows,
2480 ];
2481 }
2482
2483 /**
2484 * Stream a leads CSV. scope=all exports every lead under current filters is not
2485 * supported to keep semantics simple; caller either exports all leads or a
2486 * specific set of selected emails.
2487 */
2488 public function mxchat_export_leads() {
2489 if (!current_user_can('manage_options')) {
2490 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2491 }
2492 check_ajax_referer('mxchat_export_leads', 'security');
2493
2494 $scope = isset($_POST['scope']) ? sanitize_key($_POST['scope']) : 'all';
2495 $fields_mode = isset($_POST['fields']) ? sanitize_key($_POST['fields']) : 'email_and_name';
2496 $emails_in = isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [];
2497
2498 $emails_in_clean = [];
2499 foreach ($emails_in as $e) {
2500 $clean = sanitize_email((string) $e);
2501 if ($clean) {
2502 $emails_in_clean[] = $clean;
2503 }
2504 }
2505
2506 global $wpdb;
2507 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2508 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2509
2510 // Collect leads from transcripts.
2511 $transcripts_sql = "SELECT user_email AS email,
2512 MAX(timestamp) AS last_seen,
2513 COUNT(DISTINCT session_id) AS conversation_count
2514 FROM {$table}
2515 WHERE user_email IS NOT NULL AND user_email != ''";
2516 $params = [];
2517 if ($scope === 'selected' && !empty($emails_in_clean)) {
2518 $placeholders = implode(',', array_fill(0, count($emails_in_clean), '%s'));
2519 $transcripts_sql .= " AND user_email IN ({$placeholders})";
2520 $params = $emails_in_clean;
2521 }
2522 $transcripts_sql .= " GROUP BY user_email ORDER BY last_seen DESC";
2523
2524 $rows = !empty($params)
2525 ? $wpdb->get_results($wpdb->prepare($transcripts_sql, $params))
2526 : $wpdb->get_results($transcripts_sql);
2527
2528 // Hydrate each row with name + top page.
2529 $export_rows = [];
2530 foreach ($rows as $row) {
2531 $detail = $has_page_url_column
2532 ? $wpdb->get_row($wpdb->prepare(
2533 "SELECT user_name, originating_page_url FROM {$table}
2534 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2535 $row->email
2536 ))
2537 : $wpdb->get_row($wpdb->prepare(
2538 "SELECT user_name FROM {$table}
2539 WHERE user_email = %s ORDER BY timestamp DESC LIMIT 1",
2540 $row->email
2541 ));
2542 $export_rows[] = [
2543 'email' => $row->email,
2544 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2545 'conversation_count' => (int) $row->conversation_count,
2546 'last_seen' => $row->last_seen,
2547 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2548 ];
2549 }
2550
2551 // Include orphan + chat_deleted leads when exporting all.
2552 if ($scope === 'all') {
2553 $transcripts_emails_lc = array_flip(array_map(
2554 function ($r) { return strtolower($r['email']); },
2555 $export_rows
2556 ));
2557 foreach (self::mxchat_collect_chat_deleted_leads('') as $cd) {
2558 if (isset($transcripts_emails_lc[strtolower($cd['email'])])) continue;
2559 $transcripts_emails_lc[strtolower($cd['email'])] = true;
2560 $export_rows[] = [
2561 'email' => $cd['email'],
2562 'name' => $cd['name'],
2563 'conversation_count' => 0,
2564 'last_seen' => $cd['last_seen'],
2565 'top_page_url' => '',
2566 'consent' => isset($cd['consent']) ? $cd['consent'] : '',
2567 'consent_at' => isset($cd['consent_at']) ? $cd['consent_at'] : '',
2568 'consent_label' => isset($cd['consent_label']) ? $cd['consent_label'] : '',
2569 ];
2570 }
2571 foreach (self::mxchat_collect_orphan_leads('') as $orphan) {
2572 if (isset($transcripts_emails_lc[strtolower($orphan['email'])])) continue;
2573 $transcripts_emails_lc[strtolower($orphan['email'])] = true;
2574 $export_rows[] = [
2575 'email' => $orphan['email'],
2576 'name' => $orphan['name'],
2577 'conversation_count' => 0,
2578 'last_seen' => '',
2579 'top_page_url' => '',
2580 ];
2581 }
2582 }
2583
2584 if (empty($export_rows)) {
2585 wp_send_json_error(['message' => 'No leads to export.']);
2586 wp_die();
2587 }
2588
2589 // Consent columns (b062c4). Chat-deleted rows already carry the preserved
2590 // values; the rest read the newest session-store record for that email.
2591 // Empty consent = "not recorded" — a pre-feature capture, NOT a decline.
2592 $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent');
2593 foreach ($export_rows as &$export_consent_ref) {
2594 if (array_key_exists('consent', $export_consent_ref)) {
2595 continue;
2596 }
2597 $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($export_consent_ref['email']) : null;
2598 $export_consent_ref['consent'] = $consent ? $consent['given'] : '';
2599 $export_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : '';
2600 $export_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : '';
2601 }
2602 unset($export_consent_ref);
2603
2604 $filename = 'mxchat-leads-' . date('Y-m-d') . '.csv';
2605 header('Content-Type: text/csv');
2606 header('Content-Disposition: attachment; filename="' . $filename . '"');
2607 header('Pragma: no-cache');
2608 header('Expires: 0');
2609
2610 $output = fopen('php://output', 'w');
2611 fputs($output, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel
2612
2613 if ($fields_mode === 'email_only') {
2614 fputcsv($output, ['Email']);
2615 foreach ($export_rows as $r) {
2616 fputcsv($output, [$r['email']]);
2617 }
2618 } else {
2619 fputcsv($output, ['Email', 'Name', 'Conversations', 'Last seen', 'Top page', 'Consent', 'Consent recorded', 'Consent text shown']);
2620 foreach ($export_rows as $r) {
2621 fputcsv($output, [
2622 $r['email'],
2623 $r['name'],
2624 $r['conversation_count'],
2625 $r['last_seen'],
2626 $r['top_page_url'],
2627 $r['consent'] !== '' ? $r['consent'] : 'not recorded',
2628 $r['consent_at'],
2629 $r['consent_label'],
2630 ]);
2631 }
2632 }
2633
2634 fclose($output);
2635 wp_die();
2636 }
2637
2638 /**
2639 * Stats strip payload (independent of filters).
2640 */
2641 private static function mxchat_leads_stats($table, $has_page_url_column) {
2642 global $wpdb;
2643
2644 $total_transcripts_emails = (int) $wpdb->get_var(
2645 "SELECT COUNT(DISTINCT user_email) FROM {$table}
2646 WHERE user_email IS NOT NULL AND user_email != ''"
2647 );
2648
2649 $new_this_week = (int) $wpdb->get_var($wpdb->prepare(
2650 "SELECT COUNT(*) FROM (
2651 SELECT user_email FROM {$table}
2652 WHERE user_email IS NOT NULL AND user_email != ''
2653 GROUP BY user_email
2654 HAVING MIN(timestamp) >= %s
2655 ) AS new_leads",
2656 gmdate('Y-m-d H:i:s', strtotime('-7 days'))
2657 ));
2658
2659 $total_convos = (int) $wpdb->get_var(
2660 "SELECT COUNT(DISTINCT session_id) FROM {$table}
2661 WHERE user_email IS NOT NULL AND user_email != ''"
2662 );
2663
2664 $orphan_count = count(self::mxchat_collect_orphan_leads(''));
2665 $chat_deleted_count = self::mxchat_count_chat_deleted_leads();
2666
2667 // Total leads = unique emails across all three sources (dedup priority: transcripts > chat_deleted > orphan
2668 // is already enforced at collection time in mxchat_fetch_leads; stats re-apply it here).
2669 $total_leads = $total_transcripts_emails + $chat_deleted_count + $orphan_count;
2670
2671 $avg = $total_transcripts_emails > 0
2672 ? round($total_convos / $total_transcripts_emails, 1)
2673 : 0;
2674
2675 // Orphan % reflects *true* orphans only (pre-chat dropoffs). Chat-deleted leads are
2676 // excluded so the metric stays meaningful — admins shouldn't see their cleanups
2677 // inflate this number.
2678 $orphan_pct = $total_leads > 0
2679 ? (int) round(($orphan_count / $total_leads) * 100)
2680 : 0;
2681
2682 return [
2683 'total_leads' => $total_leads,
2684 'new_this_week' => $new_this_week,
2685 'avg_convos' => $avg,
2686 'orphan_pct' => $orphan_pct,
2687 'orphan_count' => $orphan_count,
2688 'chat_deleted_count' => $chat_deleted_count,
2689 ];
2690 }
2691
2692 /**
2693 * Collect leads who had a conversation that an admin later deleted (preserved via
2694 * mxchat_lead_del_* options). Returns rows tagged status='chat_deleted' with the
2695 * original last-seen timestamp so they still sort and filter sensibly.
2696 *
2697 * @param string $search Optional email/name substring filter.
2698 * @param string $date_cutoff Optional 'Y-m-d H:i:s' cutoff — only rows with last_ts >= cutoff.
2699 * @return array
2700 */
2701 private static function mxchat_collect_chat_deleted_leads($search = '', $date_cutoff = '') {
2702 global $wpdb;
2703 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2704
2705 $rows = $wpdb->get_results(
2706 "SELECT option_name, option_value FROM {$wpdb->options}
2707 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2708 );
2709 if (empty($rows)) {
2710 return [];
2711 }
2712
2713 // Emails that currently have transcripts rows should not appear as chat_deleted —
2714 // they've come back and chatted, so they're active leads again.
2715 $emails_in_transcripts = array_map(
2716 'strtolower',
2717 (array) $wpdb->get_col(
2718 "SELECT DISTINCT user_email FROM {$table}
2719 WHERE user_email IS NOT NULL AND user_email != ''"
2720 )
2721 );
2722 $emails_in_transcripts = array_flip($emails_in_transcripts);
2723
2724 $needle = strtolower(trim((string) $search));
2725 $by_email = [];
2726
2727 foreach ($rows as $opt) {
2728 $email = sanitize_email(trim((string) $opt->option_value));
2729 if (!$email) {
2730 continue;
2731 }
2732 if (isset($emails_in_transcripts[strtolower($email)])) {
2733 continue;
2734 }
2735 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2736 if (!$sid) {
2737 continue;
2738 }
2739 $name = (string) get_option('mxchat_lead_del_name_' . $sid, '');
2740 $ts = (string) get_option('mxchat_lead_del_ts_' . $sid, '');
2741
2742 if ($date_cutoff !== '' && ($ts === '' || $ts < $date_cutoff)) {
2743 continue;
2744 }
2745 if ($needle !== '') {
2746 $hay = strtolower($email . ' ' . $name);
2747 if (strpos($hay, $needle) === false) {
2748 continue;
2749 }
2750 }
2751
2752 $key = strtolower($email);
2753 if (!isset($by_email[$key]) || (isset($by_email[$key]['last_seen']) && $ts > $by_email[$key]['last_seen'])) {
2754 // Consent proof preserved at chat-delete time (b062c4) — the
2755 // session row is gone, so these options are the only copy.
2756 $consent_state = (string) get_option('mxchat_lead_del_consent_' . $sid, '');
2757
2758 $by_email[$key] = [
2759 'email' => $email,
2760 'name' => $name,
2761 'conversation_count' => 0,
2762 'last_seen' => $ts,
2763 'last_seen_display' => $ts ? self::mxchat_leads_format_relative($ts) : __('Chat deleted', 'mxchat'),
2764 'first_seen' => $ts,
2765 'latest_session_id' => '',
2766 'top_page_url' => '',
2767 'top_page_title' => '',
2768 'is_orphan' => false,
2769 'status' => 'chat_deleted',
2770 'consent' => $consent_state,
2771 'consent_at' => $consent_state !== '' ? (string) get_option('mxchat_lead_del_consent_at_' . $sid, '') : '',
2772 'consent_label' => $consent_state !== '' ? wp_strip_all_tags((string) get_option('mxchat_lead_del_consent_label_' . $sid, '')) : '',
2773 ];
2774 }
2775 }
2776
2777 // Newest chat_deleted first.
2778 usort($by_email, function ($a, $b) {
2779 return strcmp((string) $b['last_seen'], (string) $a['last_seen']);
2780 });
2781 return array_values($by_email);
2782 }
2783
2784 /**
2785 * Count unique emails preserved as "chat deleted" (for the stats strip).
2786 */
2787 private static function mxchat_count_chat_deleted_leads() {
2788 global $wpdb;
2789 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2790
2791 $emails = $wpdb->get_col(
2792 "SELECT DISTINCT option_value FROM {$wpdb->options}
2793 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2794 );
2795 if (empty($emails)) {
2796 return 0;
2797 }
2798
2799 $transcripts_emails = array_map(
2800 'strtolower',
2801 (array) $wpdb->get_col(
2802 "SELECT DISTINCT user_email FROM {$table}
2803 WHERE user_email IS NOT NULL AND user_email != ''"
2804 )
2805 );
2806 $transcripts_emails = array_flip($transcripts_emails);
2807
2808 $count = 0;
2809 $seen = [];
2810 foreach ($emails as $raw) {
2811 $email = strtolower(trim((string) $raw));
2812 if (!$email || isset($seen[$email]) || isset($transcripts_emails[$email])) {
2813 continue;
2814 }
2815 $seen[$email] = true;
2816 $count++;
2817 }
2818 return $count;
2819 }
2820
2821 /**
2822 * Find leads who submitted the pre-chat form but never produced a transcripts row.
2823 * Returned rows have no conversation_count, no timestamp.
2824 *
2825 * @param string $search Optional email/name substring filter.
2826 * @return array
2827 */
2828 private static function mxchat_collect_orphan_leads($search = '') {
2829 global $wpdb;
2830 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2831
2832 // Primary source since 5658f2: the sessions table's identity columns.
2833 // The legacy option scan stays for installs still mid-migration.
2834 $store_rows = (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'identity_rows'))
2835 ? MxChat_Session_Store::identity_rows()
2836 : [];
2837
2838 $option_rows = $wpdb->get_results(
2839 "SELECT option_name, option_value FROM {$wpdb->options}
2840 WHERE option_name LIKE 'mxchat_email_%'"
2841 );
2842 if (empty($option_rows) && empty($store_rows)) {
2843 return [];
2844 }
2845
2846 // Collect all session_ids that have real transcripts rows so we can exclude them.
2847 $session_ids_with_rows = $wpdb->get_col(
2848 "SELECT DISTINCT session_id FROM {$table}
2849 WHERE user_email IS NOT NULL AND user_email != ''"
2850 );
2851 $session_ids_with_rows = array_flip($session_ids_with_rows);
2852
2853 // Seen emails in transcripts (so orphans only include truly never-chatted leads).
2854 $emails_in_transcripts = array_map(
2855 'strtolower',
2856 (array) $wpdb->get_col(
2857 "SELECT DISTINCT user_email FROM {$table}
2858 WHERE user_email IS NOT NULL AND user_email != ''"
2859 )
2860 );
2861 $emails_in_transcripts = array_flip($emails_in_transcripts);
2862
2863 $orphans_by_email = [];
2864 $needle = strtolower(trim((string) $search));
2865
2866 // Normalize both sources into [sid, email, name] candidates. Table rows
2867 // first — a session can only live in one home, and dedup-by-email below
2868 // handles any overlap either way.
2869 $candidates = [];
2870 foreach ($store_rows as $row) {
2871 $candidates[] = [
2872 'sid' => (string) $row['session_id'],
2873 'email' => (string) $row['visitor_email'],
2874 'name' => is_string($row['visitor_name'] ?? null) ? $row['visitor_name'] : '',
2875 ];
2876 }
2877 foreach ($option_rows as $opt) {
2878 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2879 $name_option = $sid ? get_option('mxchat_name_' . $sid, '') : '';
2880 $candidates[] = [
2881 'sid' => (string) $sid,
2882 'email' => (string) $opt->option_value,
2883 'name' => is_string($name_option) ? $name_option : '',
2884 ];
2885 }
2886
2887 foreach ($candidates as $cand) {
2888 $email = sanitize_email(trim($cand['email']));
2889 if (!$email) {
2890 continue;
2891 }
2892 $sid = $cand['sid'];
2893 if (!$sid) {
2894 continue;
2895 }
2896 // Exclude leads who have any transcripts rows (they appear in the main list).
2897 if (isset($emails_in_transcripts[strtolower($email)])) {
2898 continue;
2899 }
2900 if (isset($session_ids_with_rows[$sid])) {
2901 continue;
2902 }
2903
2904 $name = trim($cand['name']);
2905
2906 if ($needle !== '') {
2907 $hay = strtolower($email . ' ' . $name);
2908 if (strpos($hay, $needle) === false) {
2909 continue;
2910 }
2911 }
2912
2913 $key = strtolower($email);
2914 if (!isset($orphans_by_email[$key])) {
2915 $orphans_by_email[$key] = [
2916 'email' => $email,
2917 'name' => $name,
2918 'conversation_count' => 0,
2919 'last_seen' => '',
2920 'last_seen_display' => __('No conversation yet', 'mxchat'),
2921 'first_seen' => '',
2922 'latest_session_id' => '',
2923 'top_page_url' => '',
2924 'top_page_title' => '',
2925 'is_orphan' => true,
2926 'status' => 'orphan',
2927 ];
2928 }
2929 }
2930
2931 return array_values($orphans_by_email);
2932 }
2933
2934 /**
2935 * Map a date_range key to a SQL-comparable cutoff string, or '' for all-time.
2936 */
2937 private static function mxchat_leads_date_cutoff($date_range) {
2938 switch ($date_range) {
2939 case 'today': return gmdate('Y-m-d H:i:s', strtotime('-24 hours'));
2940 case '7d': return gmdate('Y-m-d H:i:s', strtotime('-7 days'));
2941 case '30d': return gmdate('Y-m-d H:i:s', strtotime('-30 days'));
2942 case '90d': return gmdate('Y-m-d H:i:s', strtotime('-90 days'));
2943 case 'all':
2944 default: return '';
2945 }
2946 }
2947
2948 /**
2949 * Turn a UTC timestamp into a short relative display like "2h ago" or "Apr 12".
2950 */
2951 private static function mxchat_leads_format_relative($timestamp) {
2952 if (!$timestamp) {
2953 return '';
2954 }
2955 $ts = strtotime($timestamp . ' UTC');
2956 if (!$ts) {
2957 return '';
2958 }
2959 $diff = time() - $ts;
2960 if ($diff < 60) return __('just now', 'mxchat');
2961 if ($diff < 3600) return floor($diff / 60) . __('m ago', 'mxchat');
2962 if ($diff < 86400) return floor($diff / 3600) . __('h ago', 'mxchat');
2963 if ($diff < 604800) return floor($diff / 86400) . __('d ago', 'mxchat');
2964 return wp_date('M j', $ts);
2965 }
2966
2967 /**
2968 * Handle translation of chat messages via AJAX
2969 */
2970 public function mxchat_translate_messages() {
2971 if (!current_user_can('manage_options')) {
2972 wp_send_json_error(['error' => 'Insufficient permissions']);
2973 wp_die();
2974 }
2975
2976 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
2977 $target_lang = isset($_POST['target_lang']) ? sanitize_text_field($_POST['target_lang']) : 'en';
2978 $messages_json = isset($_POST['messages']) ? wp_unslash($_POST['messages']) : '[]';
2979 $messages = json_decode($messages_json, true);
2980
2981 if (empty($session_id)) {
2982 wp_send_json_error(['error' => 'No session ID provided']);
2983 wp_die();
2984 }
2985
2986 if (empty($messages) || !is_array($messages)) {
2987 wp_send_json_error(['error' => 'No messages to translate']);
2988 wp_die();
2989 }
2990
2991 // Language names for prompting
2992 $languages = [
2993 'en' => 'English',
2994 'es' => 'Spanish',
2995 'fr' => 'French',
2996 'de' => 'German',
2997 'it' => 'Italian',
2998 'pt' => 'Portuguese',
2999 'nl' => 'Dutch',
3000 'ru' => 'Russian',
3001 'zh' => 'Chinese',
3002 'ja' => 'Japanese',
3003 'ko' => 'Korean',
3004 'ar' => 'Arabic',
3005 'hi' => 'Hindi',
3006 'tr' => 'Turkish',
3007 'pl' => 'Polish',
3008 'vi' => 'Vietnamese',
3009 'th' => 'Thai',
3010 'id' => 'Indonesian',
3011 'sv' => 'Swedish',
3012 'da' => 'Danish'
3013 ];
3014
3015 $target_lang_name = isset($languages[$target_lang]) ? $languages[$target_lang] : 'English';
3016
3017 // Build combined text for translation (numbered for parsing)
3018 $numbered_messages = [];
3019 foreach ($messages as $i => $msg) {
3020 $content = isset($msg['content']) ? trim($msg['content']) : '';
3021 if (!empty($content)) {
3022 $numbered_messages[] = "[MSG" . $i . "]" . $content . "[/MSG" . $i . "]";
3023 }
3024 }
3025
3026 if (empty($numbered_messages)) {
3027 wp_send_json_error(['error' => 'No valid messages to translate']);
3028 wp_die();
3029 }
3030
3031 $combined_text = implode("\n\n", $numbered_messages);
3032
3033 // Prepare the translation prompt
3034 $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.";
3035
3036 // Get user's selected model and determine provider
3037 $options = get_option('mxchat_options', []);
3038 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
3039
3040 // Check if using OpenRouter
3041 if ($selected_model === 'openrouter') {
3042 $provider = 'openrouter';
3043 $selected_model = $options['openrouter_selected_model'] ?? '';
3044 $api_key = $options['openrouter_api_key'] ?? '';
3045
3046 if (empty($selected_model)) {
3047 wp_send_json_error(['error' => 'No OpenRouter model selected']);
3048 wp_die();
3049 }
3050 } else {
3051 // Determine provider from model name
3052 $model_parts = explode('-', $selected_model);
3053 $provider = strtolower($model_parts[0]);
3054
3055 // Get the appropriate API key based on provider
3056 $api_key = '';
3057 switch ($provider) {
3058 case 'gpt':
3059 case 'o1':
3060 $api_key = $options['api_key'] ?? '';
3061 break;
3062 case 'claude':
3063 $api_key = $options['claude_api_key'] ?? '';
3064 break;
3065 case 'grok':
3066 $api_key = $options['xai_api_key'] ?? '';
3067 break;
3068 case 'deepseek':
3069 $api_key = $options['deepseek_api_key'] ?? '';
3070 break;
3071 case 'gemini':
3072 $api_key = $options['gemini_api_key'] ?? '';
3073 break;
3074 default:
3075 // Default to OpenAI
3076 $api_key = $options['api_key'] ?? '';
3077 $provider = 'gpt';
3078 break;
3079 }
3080 }
3081
3082 if (empty($api_key)) {
3083 wp_send_json_error(['error' => 'No API key configured for ' . $provider]);
3084 wp_die();
3085 }
3086
3087 // Make API request based on provider
3088 $response = $this->translate_with_provider($provider, $selected_model, $api_key, $system_prompt, $combined_text);
3089
3090 if (is_wp_error($response)) {
3091 wp_send_json_error(['error' => $response->get_error_message()]);
3092 wp_die();
3093 }
3094
3095 // Parse the response to extract translated messages
3096 $translations = [];
3097 foreach ($messages as $msg) {
3098 $index = $msg['index'];
3099 $pattern = '/\[MSG' . $index . '\](.*?)\[\/MSG' . $index . '\]/s';
3100 if (preg_match($pattern, $response, $matches)) {
3101 $translations[] = [
3102 'index' => $index,
3103 'translated' => trim($matches[1])
3104 ];
3105 }
3106 }
3107
3108 // Save translations to database
3109 if (!empty($translations)) {
3110 $this->save_transcript_translation($session_id, $target_lang, $translations);
3111 }
3112
3113 wp_send_json(['success' => true, 'translations' => $translations, 'language' => $target_lang]);
3114 wp_die();
3115 }
3116
3117 /**
3118 * Save transcript translation to database
3119 */
3120 private function save_transcript_translation($session_id, $language_code, $translations) {
3121 global $wpdb;
3122 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
3123
3124 // Check if table exists, create if not
3125 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
3126 mxchat_create_translations_table();
3127 }
3128
3129 $now = current_time('mysql');
3130 $translations_json = wp_json_encode($translations);
3131
3132 // Use REPLACE to insert or update
3133 $wpdb->query($wpdb->prepare(
3134 "REPLACE INTO $table_name (session_id, language_code, translations, created_at, updated_at)
3135 VALUES (%s, %s, %s, %s, %s)",
3136 $session_id,
3137 $language_code,
3138 $translations_json,
3139 $now,
3140 $now
3141 ));
3142 }
3143
3144 /**
3145 * Get saved translation for a session
3146 */
3147 public function mxchat_get_transcript_translation() {
3148 if (!current_user_can('manage_options')) {
3149 wp_send_json_error(['error' => 'Insufficient permissions']);
3150 wp_die();
3151 }
3152
3153 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3154
3155 if (empty($session_id)) {
3156 wp_send_json_error(['error' => 'No session ID provided']);
3157 wp_die();
3158 }
3159
3160 global $wpdb;
3161 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
3162
3163 // Check if table exists
3164 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
3165 wp_send_json(['success' => true, 'has_translation' => false]);
3166 wp_die();
3167 }
3168
3169 // Get the most recent translation for this session
3170 $result = $wpdb->get_row($wpdb->prepare(
3171 "SELECT language_code, translations FROM $table_name WHERE session_id = %s ORDER BY updated_at DESC LIMIT 1",
3172 $session_id
3173 ));
3174
3175 if ($result) {
3176 $translations = json_decode($result->translations, true);
3177 wp_send_json([
3178 'success' => true,
3179 'has_translation' => true,
3180 'language' => $result->language_code,
3181 'translations' => $translations
3182 ]);
3183 } else {
3184 wp_send_json(['success' => true, 'has_translation' => false]);
3185 }
3186 wp_die();
3187 }
3188
3189 /**
3190 * Translate text using the user's selected provider and model
3191 */
3192 private function translate_with_provider($provider, $model, $api_key, $system_prompt, $text) {
3193 switch ($provider) {
3194 case 'claude':
3195 return $this->translate_with_claude($api_key, $model, $system_prompt, $text);
3196 case 'grok':
3197 return $this->translate_with_xai($api_key, $model, $system_prompt, $text);
3198 case 'deepseek':
3199 return $this->translate_with_deepseek($api_key, $model, $system_prompt, $text);
3200 case 'gemini':
3201 return $this->translate_with_gemini($api_key, $model, $system_prompt, $text);
3202 case 'openrouter':
3203 return $this->translate_with_openrouter($api_key, $model, $system_prompt, $text);
3204 case 'gpt':
3205 case 'o1':
3206 default:
3207 return $this->translate_with_openai($api_key, $model, $system_prompt, $text);
3208 }
3209 }
3210
3211 /**
3212 * Translate text using OpenAI API
3213 */
3214 private function translate_with_openai($api_key, $model, $system_prompt, $text) {
3215 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
3216 'timeout' => 60,
3217 'headers' => [
3218 'Authorization' => 'Bearer ' . $api_key,
3219 'Content-Type' => 'application/json'
3220 ],
3221 'body' => wp_json_encode([
3222 'model' => $model,
3223 'messages' => [
3224 ['role' => 'system', 'content' => $system_prompt],
3225 ['role' => 'user', 'content' => $text]
3226 ],
3227 'temperature' => 0.3
3228 ])
3229 ]);
3230
3231 if (is_wp_error($response)) {
3232 return $response;
3233 }
3234
3235 $body = json_decode(wp_remote_retrieve_body($response), true);
3236
3237 if (isset($body['error'])) {
3238 return new WP_Error('api_error', $body['error']['message']);
3239 }
3240
3241 if (isset($body['choices'][0]['message']['content'])) {
3242 return $body['choices'][0]['message']['content'];
3243 }
3244
3245 return new WP_Error('api_error', 'Invalid API response');
3246 }
3247
3248 /**
3249 * Translate text using Claude API
3250 */
3251 private function translate_with_claude($api_key, $model, $system_prompt, $text) {
3252 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3253 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3254 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3255 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3256 $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
3257 'timeout' => 60,
3258 'headers' => [
3259 'x-api-key' => $api_key,
3260 'anthropic-version' => '2023-06-01',
3261 'Content-Type' => 'application/json'
3262 ],
3263 'body' => wp_json_encode([
3264 'model' => $model,
3265 'max_tokens' => 4096,
3266 // Prompt-cache breakpoint (plan 1ff43b): the translate instruction
3267 // repeats across every message in a batch, so cache reads bill at
3268 // 0.1x. Below the model's minimum prefix it silently no-ops.
3269 'system' => trim((string) $system_prompt) === '' ? $system_prompt : [
3270 ['type' => 'text', 'text' => $system_prompt, 'cache_control' => ['type' => 'ephemeral']]
3271 ],
3272 'messages' => [
3273 ['role' => 'user', 'content' => $text]
3274 ]
3275 ])
3276 ]);
3277
3278 if (is_wp_error($response)) {
3279 return $response;
3280 }
3281
3282 $body = json_decode(wp_remote_retrieve_body($response), true);
3283
3284 if (isset($body['error'])) {
3285 return new WP_Error('api_error', $body['error']['message']);
3286 }
3287
3288 if (isset($body['content'][0]['text'])) {
3289 return $body['content'][0]['text'];
3290 }
3291
3292 return new WP_Error('api_error', 'Invalid API response');
3293 }
3294
3295 /**
3296 * Translate text using xAI (Grok) API
3297 */
3298 private function translate_with_xai($api_key, $model, $system_prompt, $text) {
3299 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', [
3300 'timeout' => 60,
3301 'headers' => [
3302 'Authorization' => 'Bearer ' . $api_key,
3303 'Content-Type' => 'application/json'
3304 ],
3305 'body' => wp_json_encode([
3306 'model' => $model,
3307 'messages' => [
3308 ['role' => 'system', 'content' => $system_prompt],
3309 ['role' => 'user', 'content' => $text]
3310 ],
3311 'temperature' => 0.3
3312 ])
3313 ]);
3314
3315 if (is_wp_error($response)) {
3316 return $response;
3317 }
3318
3319 $body = json_decode(wp_remote_retrieve_body($response), true);
3320
3321 if (isset($body['error'])) {
3322 return new WP_Error('api_error', $body['error']['message']);
3323 }
3324
3325 if (isset($body['choices'][0]['message']['content'])) {
3326 return $body['choices'][0]['message']['content'];
3327 }
3328
3329 return new WP_Error('api_error', 'Invalid API response');
3330 }
3331
3332 /**
3333 * Translate text using DeepSeek API
3334 */
3335 private function translate_with_deepseek($api_key, $model, $system_prompt, $text) {
3336 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', [
3337 'timeout' => 60,
3338 'headers' => [
3339 'Authorization' => 'Bearer ' . $api_key,
3340 'Content-Type' => 'application/json'
3341 ],
3342 'body' => wp_json_encode([
3343 'model' => $model,
3344 'messages' => [
3345 ['role' => 'system', 'content' => $system_prompt],
3346 ['role' => 'user', 'content' => $text]
3347 ],
3348 'temperature' => 0.3,
3349 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3350 // slow responses); translation wants non-thinking.
3351 'thinking' => ['type' => 'disabled']
3352 ])
3353 ]);
3354
3355 if (is_wp_error($response)) {
3356 return $response;
3357 }
3358
3359 $body = json_decode(wp_remote_retrieve_body($response), true);
3360
3361 if (isset($body['error'])) {
3362 return new WP_Error('api_error', $body['error']['message']);
3363 }
3364
3365 if (isset($body['choices'][0]['message']['content'])) {
3366 return $body['choices'][0]['message']['content'];
3367 }
3368
3369 return new WP_Error('api_error', 'Invalid API response');
3370 }
3371
3372 /**
3373 * Translate text using Google Gemini API
3374 */
3375 private function translate_with_gemini($api_key, $model, $system_prompt, $text) {
3376 if ($model === 'gemini-3-pro-preview') {
3377 $model = 'gemini-3.1-pro-preview';
3378 }
3379 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
3380
3381 $response = wp_remote_post($url, [
3382 'timeout' => 60,
3383 'headers' => [
3384 'Content-Type' => 'application/json'
3385 ],
3386 'body' => wp_json_encode([
3387 'contents' => [
3388 [
3389 'parts' => [
3390 ['text' => $system_prompt . "\n\n" . $text]
3391 ]
3392 ]
3393 ],
3394 'generationConfig' => [
3395 'temperature' => 0.3
3396 ]
3397 ])
3398 ]);
3399
3400 if (is_wp_error($response)) {
3401 return $response;
3402 }
3403
3404 $body = json_decode(wp_remote_retrieve_body($response), true);
3405
3406 if (isset($body['error'])) {
3407 return new WP_Error('api_error', $body['error']['message']);
3408 }
3409
3410 if (isset($body['candidates'][0]['content']['parts'][0]['text'])) {
3411 return $body['candidates'][0]['content']['parts'][0]['text'];
3412 }
3413
3414 return new WP_Error('api_error', 'Invalid API response');
3415 }
3416
3417 /**
3418 * Translate text using OpenRouter API
3419 */
3420 private function translate_with_openrouter($api_key, $model, $system_prompt, $text) {
3421 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [
3422 'timeout' => 60,
3423 'headers' => [
3424 'Authorization' => 'Bearer ' . $api_key,
3425 'Content-Type' => 'application/json',
3426 'HTTP-Referer' => home_url(),
3427 'X-Title' => 'MxChat Translation'
3428 ],
3429 'body' => wp_json_encode([
3430 'model' => $model,
3431 'messages' => [
3432 ['role' => 'system', 'content' => $system_prompt],
3433 ['role' => 'user', 'content' => $text]
3434 ],
3435 'temperature' => 0.3
3436 ])
3437 ]);
3438
3439 if (is_wp_error($response)) {
3440 return $response;
3441 }
3442
3443 $body = json_decode(wp_remote_retrieve_body($response), true);
3444
3445 if (isset($body['error'])) {
3446 return new WP_Error('api_error', $body['error']['message']);
3447 }
3448
3449 if (isset($body['choices'][0]['message']['content'])) {
3450 return $body['choices'][0]['message']['content'];
3451 }
3452
3453 return new WP_Error('api_error', 'Invalid API response');
3454 }
3455
3456 public function mxchat_fetch_chat_history() {
3457 global $wpdb;
3458 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3459 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3460
3461 if (!current_user_can('manage_options')) {
3462 wp_send_json_error(['message' => 'Insufficient permissions']);
3463 wp_die();
3464 }
3465
3466 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3467 $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 50;
3468 $offset = ($page - 1) * $per_page;
3469 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3470 $sort_raw = isset($_POST['sort_order']) ? sanitize_key($_POST['sort_order']) : 'desc';
3471 $allowed_sorts = array('asc', 'desc', 'rating_positive', 'rating_negative');
3472 if (!in_array($sort_raw, $allowed_sorts, true)) { $sort_raw = 'desc'; }
3473 $sort_order = ($sort_raw === 'asc') ? 'ASC' : 'DESC';
3474 $ratings_table = $wpdb->prefix . 'mxchat_session_ratings';
3475 $ratings_join = '';
3476 $rating_table_exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $ratings_table)) === $ratings_table);
3477 if ($rating_table_exists && ($sort_raw === 'rating_positive' || $sort_raw === 'rating_negative')) {
3478 // We sort by rating then recency. Wrap rating_value so NULL sorts last.
3479 $rating_dir = ($sort_raw === 'rating_positive') ? 'DESC' : 'ASC';
3480 $ratings_join = " LEFT JOIN {$ratings_table} r ON r.session_id = t.session_id ";
3481 }
3482
3483 // Build search condition
3484 $search_condition = '';
3485 $search_params = [];
3486 if (!empty($search)) {
3487 $search_condition = "WHERE (
3488 session_id LIKE %s
3489 OR user_email LIKE %s
3490 OR user_name LIKE %s
3491 OR user_identifier LIKE %s
3492 OR message LIKE %s
3493 )";
3494 $search_params = array_fill(0, 5, '%' . $wpdb->esc_like($search) . '%');
3495 }
3496
3497 // Get total count
3498 $count_query = !empty($search)
3499 ? $wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table_name} {$search_condition}", $search_params)
3500 : "SELECT COUNT(DISTINCT session_id) FROM {$table_name}";
3501 $total_sessions = (int) $wpdb->get_var($count_query);
3502
3503 // Get session IDs for current page. Default sort is recency; rating sorts join the ratings table
3504 // and order by rating value (NULLs last) with recency as tiebreaker.
3505 if ($ratings_join) {
3506 $order_by = "ORDER BY (r.rating_value IS NULL), r.rating_value {$rating_dir}, MAX(t.timestamp) DESC";
3507 } else {
3508 $order_by = "ORDER BY MAX(t.timestamp) {$sort_order}";
3509 }
3510 $session_query = !empty($search)
3511 ? $wpdb->prepare(
3512 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} {$search_condition}
3513 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3514 array_merge($search_params, [$per_page, $offset])
3515 )
3516 : $wpdb->prepare(
3517 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join}
3518 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3519 $per_page, $offset
3520 );
3521 $session_ids = $wpdb->get_col($session_query);
3522
3523 $total_pages = ceil($total_sessions / $per_page);
3524
3525 if (empty($session_ids)) {
3526 wp_send_json([
3527 'success' => true,
3528 'sessions' => [],
3529 'page' => $page,
3530 'total_pages' => 0,
3531 'total_sessions' => 0,
3532 'showing_start' => 0,
3533 'showing_end' => 0
3534 ]);
3535 wp_die();
3536 }
3537
3538 // Check for optional columns/tables
3539 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3540 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3541
3542 // Batch-fetch session ratings for this page (plan-a5b006).
3543 $ratings_map = array();
3544 if ($rating_table_exists && !empty($session_ids)) {
3545 $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
3546 $rating_rows = $wpdb->get_results($wpdb->prepare(
3547 "SELECT session_id, rating_value, rating_feedback FROM {$ratings_table} WHERE session_id IN ($placeholders)",
3548 $session_ids
3549 ));
3550 foreach ($rating_rows as $row) {
3551 $ratings_map[$row->session_id] = array(
3552 'value' => (int) $row->rating_value,
3553 'feedback' => (string) $row->rating_feedback,
3554 );
3555 }
3556 }
3557
3558 // Build session list data
3559 $sessions = [];
3560 foreach ($session_ids as $session_id) {
3561 // Get session metadata
3562 $session_data = $originating_columns_exist
3563 ? $wpdb->get_row($wpdb->prepare(
3564 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3565 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3566 $session_id
3567 ))
3568 : $wpdb->get_row($wpdb->prepare(
3569 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3570 WHERE session_id = %s LIMIT 1",
3571 $session_id
3572 ));
3573
3574 // Get message count and latest timestamp
3575 $message_stats = $wpdb->get_row($wpdb->prepare(
3576 "SELECT COUNT(*) as count, MAX(timestamp) as latest FROM {$table_name} WHERE session_id = %s",
3577 $session_id
3578 ));
3579
3580 // Get first user message as preview
3581 $first_user_msg = $wpdb->get_var($wpdb->prepare(
3582 "SELECT message FROM {$table_name} WHERE session_id = %s AND role = 'user' ORDER BY timestamp ASC LIMIT 1",
3583 $session_id
3584 ));
3585 $preview = $first_user_msg ? wp_trim_words(wp_strip_all_tags(stripslashes($first_user_msg)), 12, '...') : 'No messages';
3586
3587 // Build display name
3588 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3589 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3590 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3591
3592 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3593 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3594
3595 // Format time - show relative for recent, date for older
3596 $timestamp = strtotime($message_stats->latest . ' UTC');
3597 $now = time();
3598 $diff = $now - $timestamp;
3599 if ($diff < 3600) {
3600 $time_display = floor($diff / 60) . 'm ago';
3601 } elseif ($diff < 86400) {
3602 $time_display = floor($diff / 3600) . 'h ago';
3603 } elseif ($diff < 604800) {
3604 $time_display = floor($diff / 86400) . 'd ago';
3605 } else {
3606 $time_display = wp_date('M j', $timestamp);
3607 }
3608
3609 // Get initials for avatar
3610 $initials = strtoupper(substr($display_name, 0, 2));
3611 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3612 $parts = explode(' ', $display_name);
3613 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3614 }
3615
3616 $rating_entry = isset($ratings_map[$session_id]) ? $ratings_map[$session_id] : null;
3617 $rating_value = $rating_entry ? $rating_entry['value'] : null;
3618 $rating_feedback = $rating_entry ? $rating_entry['feedback'] : '';
3619
3620 $sessions[] = [
3621 'session_id' => $session_id,
3622 'display_name' => $display_name,
3623 'display_sub' => $display_sub,
3624 'initials' => $initials,
3625 'preview' => $preview,
3626 'message_count' => (int) $message_stats->count,
3627 'time_display' => $time_display,
3628 'timestamp' => $message_stats->latest,
3629 'rating_value' => $rating_value,
3630 'rating_feedback' => $rating_feedback,
3631 ];
3632 }
3633
3634 wp_send_json([
3635 'success' => true,
3636 'sessions' => $sessions,
3637 'page' => $page,
3638 'total_pages' => $total_pages,
3639 'total_sessions' => $total_sessions,
3640 'showing_start' => $offset + 1,
3641 'showing_end' => min($offset + $per_page, $total_sessions)
3642 ]);
3643 wp_die();
3644 }
3645
3646 /**
3647 * Fetch single conversation details for split-panel view
3648 */
3649 public function mxchat_fetch_conversation() {
3650 global $wpdb;
3651 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3652 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3653
3654 if (!current_user_can('manage_options')) {
3655 wp_send_json_error(['message' => 'Insufficient permissions']);
3656 wp_die();
3657 }
3658
3659 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3660 if (empty($session_id)) {
3661 wp_send_json_error(['message' => 'No session ID provided']);
3662 wp_die();
3663 }
3664
3665 // Check for optional columns/tables
3666 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3667 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3668
3669 // Get session metadata
3670 $session_data = $originating_columns_exist
3671 ? $wpdb->get_row($wpdb->prepare(
3672 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3673 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3674 $session_id
3675 ))
3676 : $wpdb->get_row($wpdb->prepare(
3677 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3678 WHERE session_id = %s LIMIT 1",
3679 $session_id
3680 ));
3681
3682 if (!$session_data) {
3683 wp_send_json_error(['message' => 'Session not found']);
3684 wp_die();
3685 }
3686
3687 // Get clicked URLs
3688 $clicked_urls = [];
3689 if ($url_table_exists) {
3690 $url_clicks = $wpdb->get_results($wpdb->prepare(
3691 "SELECT DISTINCT clicked_url FROM {$url_clicks_table}
3692 WHERE session_id = %s ORDER BY click_timestamp ASC",
3693 $session_id
3694 ));
3695 foreach ($url_clicks as $click) {
3696 $clicked_urls[] = $click->clicked_url;
3697 }
3698 }
3699
3700 // Get all messages
3701 $messages = $wpdb->get_results($wpdb->prepare(
3702 "SELECT * FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC",
3703 $session_id
3704 ));
3705
3706 // Build user info
3707 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3708 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3709 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3710
3711 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3712 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3713
3714 // Get initials
3715 $initials = strtoupper(substr($display_name, 0, 2));
3716 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3717 $parts = explode(' ', $display_name);
3718 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3719 }
3720
3721 // Page info
3722 $page_url = $originating_columns_exist && !empty($session_data->originating_page_url) ? $session_data->originating_page_url : '';
3723 $page_title = '';
3724 if ($page_url) {
3725 $page_title = !empty($session_data->originating_page_title) ? $session_data->originating_page_title : parse_url($page_url, PHP_URL_PATH);
3726 }
3727
3728 // Format messages for output
3729 $formatted_messages = [];
3730 foreach ($messages as $msg) {
3731 $is_user = ($msg->role === 'user');
3732 // Live-agent replies (Slack/Telegram handoff) persist with role 'agent'.
3733 // Kept OUT of $is_bot so the RAG Sources link below stays bot-only.
3734 $is_agent = ($msg->role === 'agent');
3735 $is_bot = ($msg->role === 'bot' || $msg->role === 'assistant');
3736
3737 $content = wp_kses(
3738 stripslashes($msg->message),
3739 [
3740 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3741 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3742 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []],
3743 'div' => ['class' => [], 'id' => [], 'data-nonce' => []],
3744 'img' => ['src' => [], 'alt' => [], 'class' => []],
3745 'h3' => ['class' => []],
3746 'h4' => ['class' => []],
3747 'button' => ['type' => [], 'class' => [], 'data-product-id' => [], 'data-nonce' => [], 'data-product-type' => [], 'data-original-text' => [], 'data-mxchat-action' => []],
3748 'select' => ['class' => [], 'data-attribute' => []],
3749 'option' => ['value' => []],
3750 'span' => ['class' => []],
3751 'del' => [], 'ins' => [],
3752 ]
3753 );
3754 $formatted_content = $this->format_transcript_message($content);
3755
3756 $has_rag = $is_bot && !empty($msg->rag_context);
3757
3758 $formatted_messages[] = [
3759 'id' => $msg->id,
3760 'role' => $msg->role,
3761 'is_user' => $is_user,
3762 'is_agent' => $is_agent,
3763 'is_bot' => $is_bot,
3764 'content' => $formatted_content,
3765 'timestamp' => wp_date('g:i A', strtotime($msg->timestamp . ' UTC')),
3766 'full_timestamp' => wp_date('F j, Y g:i A', strtotime($msg->timestamp . ' UTC')),
3767 'has_rag' => $has_rag
3768 ];
3769 }
3770
3771 // First message timestamp for "started" display
3772 $started = !empty($messages) ? wp_date('M j, Y g:i A', strtotime($messages[0]->timestamp . ' UTC')) : '-';
3773
3774 // Pull rating_feedback for this session (if any) so the details drawer can show it.
3775 $rating_feedback = '';
3776 $ratings_table_det = $wpdb->prefix . 'mxchat_session_ratings';
3777 if ($wpdb->get_var("SHOW TABLES LIKE '$ratings_table_det'") === $ratings_table_det) {
3778 $rating_feedback = (string) $wpdb->get_var($wpdb->prepare(
3779 "SELECT rating_feedback FROM {$ratings_table_det} WHERE session_id = %s LIMIT 1",
3780 $session_id
3781 ));
3782 }
3783
3784 wp_send_json([
3785 'success' => true,
3786 'session_id' => $session_id,
3787 'user' => [
3788 'name' => $display_name,
3789 'sub' => $display_sub,
3790 'initials' => $initials,
3791 'email' => $user_email,
3792 'identifier' => $user_identifier
3793 ],
3794 'page' => [
3795 'url' => $page_url,
3796 'title' => $page_title
3797 ],
3798 'clicked_urls' => $clicked_urls,
3799 'messages' => $formatted_messages,
3800 'message_count' => count($messages),
3801 'started' => $started,
3802 'rating_feedback' => $rating_feedback
3803 ]);
3804 wp_die();
3805 }
3806
3807
3808 /**
3809 * ALTERNATIVE: Simpler helper method using string replacement
3810 */
3811 private function highlight_clicked_links($message_content, $clicked_urls) {
3812 if (empty($clicked_urls)) {
3813 return wp_kses(
3814 $message_content,
3815 [
3816 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3817 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3818 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
3819 ]
3820 );
3821 }
3822
3823 // First apply standard sanitization
3824 $message_content = wp_kses(
3825 $message_content,
3826 [
3827 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3828 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3829 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
3830 ]
3831 );
3832
3833 // Process each clicked URL
3834 foreach ($clicked_urls as $clicked_url) {
3835 // Try multiple patterns to catch different link formats
3836 $patterns = [
3837 // Standard link format
3838 '/<a([^>]*href=["\']' . preg_quote($clicked_url, '/') . '["\'][^>]*)>/i',
3839 // Link with trailing slash
3840 '/<a([^>]*href=["\']' . preg_quote(rtrim($clicked_url, '/'), '/') . '\/?["\'][^>]*)>/i',
3841 // Encoded entities version
3842 '/<a([^>]*href=["\']' . preg_quote(htmlentities($clicked_url), '/') . '["\'][^>]*)>/i',
3843 ];
3844
3845 foreach ($patterns as $pattern) {
3846 if (preg_match($pattern, $message_content)) {
3847 $message_content = preg_replace_callback(
3848 $pattern,
3849 function($matches) {
3850 $full_match = $matches[0];
3851 $attributes = $matches[1];
3852
3853 // Check if it already has the class
3854 if (strpos($full_match, 'mxchat-clicked-link') !== false) {
3855 return $full_match;
3856 }
3857
3858 // Check if class attribute exists
3859 if (preg_match('/class=["\']([^"\']*)["\']/', $attributes, $class_matches)) {
3860 // Add to existing class
3861 $new_attributes = preg_replace(
3862 '/class=["\']([^"\']*)["\']/',
3863 'class="$1 mxchat-clicked-link"',
3864 $attributes
3865 );
3866 } else {
3867 // Add new class attribute
3868 $new_attributes = $attributes . ' class="mxchat-clicked-link"';
3869 }
3870
3871 // Add title if not present
3872 if (strpos($new_attributes, 'title=') === false) {
3873 $new_attributes .= ' title="User clicked this link"';
3874 }
3875
3876 return '<a' . $new_attributes . '>';
3877 },
3878 $message_content
3879 );
3880
3881 // If we found and replaced, break out of the patterns loop
3882 break;
3883 }
3884 }
3885 }
3886
3887 return $message_content;
3888 }
3889
3890 public function mxchat_create_prompts_page() {
3891 //error_log('=== DEBUG: mxchat_create_prompts_page started ===');
3892
3893 global $wpdb;
3894 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3895
3896 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
3897 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
3898
3899 // Display success message if all prompts were deleted
3900 if (isset($_GET['all_deleted']) && $_GET['all_deleted'] === 'true') {
3901 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>';
3902 }
3903
3904 // Set up pagination, search query, and content type filter
3905 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field($_GET['_wpnonce']) : '';
3906 $search_query = (!empty($nonce) && wp_verify_nonce($nonce, 'mxchat_prompts_search_nonce') && isset($_GET['search'])) ? sanitize_text_field($_GET['search']) : '';
3907 $content_type_filter = isset($_GET['content_type']) ? sanitize_key($_GET['content_type']) : ''; // ADDED 2.5.6
3908 $current_page = isset($_GET['paged']) ? absint($_GET['paged']) : 1;
3909 $per_page = 25;
3910
3911 //error_log('DEBUG: Search query: ' . $search_query);
3912 //error_log('DEBUG: Content type filter: ' . $content_type_filter);
3913 //error_log('DEBUG: Current page: ' . $current_page);
3914 //error_log('DEBUG: Per page: ' . $per_page);
3915
3916 // ================================
3917 // MULTI-BOT CONFIGURATION
3918 // ================================
3919
3920 // Check for multi-bot and set up bot selection
3921 if (class_exists('MxChat_Multi_Bot_Manager')) {
3922 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
3923 $available_bots = $multi_bot_manager->get_available_bots();
3924
3925 // Get saved bot selection (user-specific first, then site-wide default)
3926 $user_id = get_current_user_id();
3927 $saved_bot_id = get_user_meta($user_id, 'mxchat_selected_knowledge_bot', true);
3928 if (empty($saved_bot_id)) {
3929 $saved_bot_id = get_option('mxchat_current_knowledge_bot', 'default');
3930 }
3931
3932 // Allow URL override but default to saved selection
3933 $current_bot_id = isset($_GET['bot_id']) ? sanitize_key($_GET['bot_id']) : $saved_bot_id;
3934 $multibot_active = true;
3935 } else {
3936 $current_bot_id = 'default';
3937 $multibot_active = false;
3938 }
3939
3940 // ================================
3941 // DATA SOURCE CONFIGURATION
3942 // ================================
3943
3944 // Get bot-specific Pinecone settings
3945 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($current_bot_id);
3946 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
3947 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3948
3949 // Get OpenAI Vector Store settings
3950 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
3951 $use_vectorstore = ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1';
3952
3953 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
3954 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Has API Key: ' . (!empty($pinecone_api_key) ? 'YES' : 'NO'));
3955 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Host: ' . ($pinecone_options['mxchat_pinecone_host'] ?? 'NOT SET'));
3956 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Namespace: ' . ($pinecone_options['mxchat_pinecone_namespace'] ?? 'NOT SET'));
3957
3958 if ($use_pinecone && !empty($pinecone_api_key)) {
3959 //error_log('DEBUG: Using PINECONE data source with bot-specific config');
3960 // PINECONE DATA SOURCE
3961 $data_source = 'pinecone';
3962
3963 // IMPORTANT: Pass the bot-specific $pinecone_options, not default options!
3964 // UPDATED 2.5.6: Added content_type_filter parameter
3965 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $current_page, $per_page, $current_bot_id, $content_type_filter);
3966
3967 // TEMPORARY DEBUG - Add this right after the fetch call
3968 //error_log('=== DEBUG: Bot switching issue ===');
3969 //error_log('Current bot ID: ' . $current_bot_id);
3970 //error_log('Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
3971 //error_log('Records returned: ' . count($records['data'] ?? []));
3972 //error_log('Total records: ' . ($records['total'] ?? 0));
3973
3974 // Check first few records to see their bot_id
3975 if (!empty($records['data'])) {
3976 foreach (array_slice($records['data'], 0, 3) as $i => $record) {
3977 $record_bot_id = $record->bot_id ?? 'NOT_SET';
3978 //error_log('Record ' . ($i+1) . ' bot_id: ' . $record_bot_id . ', content preview: ' . substr($record->article_content ?? '', 0, 30) . '...');
3979 }
3980 }
3981 //error_log('=== END DEBUG ===');
3982
3983 $total_records = $records['total'] ?? 0;
3984 $prompts = $records['data'] ?? array();
3985 $total_in_database = $records['total_in_database'] ?? 0;
3986 $showing_recent_only = $records['showing_recent_only'] ?? false;
3987
3988 $total_pages = ceil($total_records / $per_page);
3989
3990 } else {
3991 //error_log('DEBUG: Using WORDPRESS DB data source');
3992 // WORDPRESS DB DATA SOURCE (your existing logic)
3993 $data_source = 'wordpress';
3994
3995 // Initialize these variables for WordPress DB
3996 $total_in_database = 0;
3997 $showing_recent_only = false;
3998
3999 $offset = ($current_page - 1) * $per_page;
4000
4001 // UPDATED 2.5.6: Build WHERE clause for search and content type filtering
4002 $where_clauses = array();
4003 $where_values = array();
4004
4005 if ($search_query) {
4006 $where_clauses[] = "article_content LIKE %s";
4007 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
4008 }
4009
4010 if ($content_type_filter) {
4011 $where_clauses[] = "content_type = %s";
4012 $where_values[] = $content_type_filter;
4013 }
4014
4015 $sql_where = "";
4016 if (!empty($where_clauses)) {
4017 $sql_where = "WHERE " . implode(" AND ", $where_clauses);
4018 }
4019
4020 // UPDATED 2.6.3: Count unique entries (by source_url) instead of individual rows
4021 // This ensures pagination shows X entries per page, not X chunks
4022 // Entries with empty source_url are counted individually
4023 if (!empty($where_values)) {
4024 // Count unique source_urls + count of rows with empty source_url
4025 $count_query = $wpdb->prepare(
4026 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$sql_where} AND source_url != '') +
4027 (SELECT COUNT(*) FROM {$table_name} {$sql_where} AND (source_url = '' OR source_url IS NULL))",
4028 array_merge($where_values, $where_values)
4029 );
4030 } else {
4031 $count_query = "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '') +
4032 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL)";
4033 }
4034 $total_records = $wpdb->get_var($count_query);
4035 $total_pages = ceil($total_records / $per_page);
4036
4037 // UPDATED 2.6.3: Get unique source_urls for pagination, then fetch all their rows
4038 // Step 1: Get the source_urls for this page (distinct URLs ordered by latest timestamp)
4039 if (!empty($where_values)) {
4040 $urls_query = $wpdb->prepare(
4041 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name} {$sql_where}
4042 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
4043 array_merge($where_values, array($per_page, $offset))
4044 );
4045 } else {
4046 $urls_query = $wpdb->prepare(
4047 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
4048 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
4049 $per_page, $offset
4050 );
4051 }
4052 $page_urls = $wpdb->get_results($urls_query);
4053
4054 // Step 2: Fetch all rows for these source_urls
4055 $prompts = array();
4056 if (!empty($page_urls)) {
4057 // Build URL order map to preserve newest-first ordering from step 1
4058 $url_order_map = array();
4059 $url_list = array();
4060 $has_empty_url = false;
4061 $order_index = 0;
4062 foreach ($page_urls as $url_row) {
4063 if (empty($url_row->source_url)) {
4064 $has_empty_url = true;
4065 $url_order_map['__empty__'] = $order_index++;
4066 } else {
4067 $url_list[] = $url_row->source_url;
4068 $url_order_map[$url_row->source_url] = $order_index++;
4069 }
4070 }
4071
4072 // Build query to fetch all rows for these URLs
4073 $url_conditions = array();
4074 $url_values = array();
4075
4076 if (!empty($url_list)) {
4077 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
4078 $url_conditions[] = "source_url IN ($placeholders)";
4079 $url_values = array_merge($url_values, $url_list);
4080 }
4081
4082 if ($has_empty_url) {
4083 $url_conditions[] = "(source_url = '' OR source_url IS NULL)";
4084 }
4085
4086 if (!empty($url_conditions)) {
4087 $url_where = "WHERE (" . implode(" OR ", $url_conditions) . ")";
4088
4089 // Add original filters back
4090 if (!empty($where_clauses)) {
4091 $url_where .= " AND " . implode(" AND ", $where_clauses);
4092 $url_values = array_merge($url_values, $where_values);
4093 }
4094
4095 if (!empty($url_values)) {
4096 $prompts_query = $wpdb->prepare(
4097 "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC",
4098 $url_values
4099 );
4100 } else {
4101 $prompts_query = "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC";
4102 }
4103
4104 $prompts = $wpdb->get_results($prompts_query);
4105
4106 // Sort prompts by the original URL order (newest first), then by timestamp within each URL group
4107 usort($prompts, function($a, $b) use ($url_order_map) {
4108 $url_a = empty($a->source_url) ? '__empty__' : $a->source_url;
4109 $url_b = empty($b->source_url) ? '__empty__' : $b->source_url;
4110 $order_a = $url_order_map[$url_a] ?? PHP_INT_MAX;
4111 $order_b = $url_order_map[$url_b] ?? PHP_INT_MAX;
4112
4113 // First sort by URL order (newest URLs first)
4114 if ($order_a !== $order_b) {
4115 return $order_a - $order_b;
4116 }
4117
4118 // Within same URL, sort by timestamp DESC (newest chunks first)
4119 return strtotime($b->timestamp) - strtotime($a->timestamp);
4120 });
4121 }
4122 }
4123 }
4124
4125 // ================================
4126 // PAGINATION GENERATION
4127 // ================================
4128
4129 // Generate pagination links
4130 if ($total_pages > 1) {
4131 // Build a clean base URL with only necessary parameters
4132 $pagination_args = array('page' => 'mxchat-prompts');
4133
4134 // Preserve bot_id parameter if multi-bot is active
4135 if ($multibot_active && !empty($current_bot_id) && $current_bot_id !== 'default') {
4136 $pagination_args['bot_id'] = $current_bot_id;
4137 }
4138
4139 // Preserve search query and nonce if present
4140 if ($search_query) {
4141 $pagination_args['search'] = $search_query;
4142 if (!empty($nonce)) {
4143 $pagination_args['_wpnonce'] = $nonce;
4144 }
4145 }
4146
4147 // Preserve content type filter if present
4148 if ($content_type_filter) {
4149 $pagination_args['content_type'] = $content_type_filter;
4150 }
4151
4152 // Build clean base URL
4153 $base_url = add_query_arg($pagination_args, admin_url('admin.php'));
4154
4155 $page_links = paginate_links(array(
4156 'base' => $base_url . '%_%',
4157 'format' => '&paged=%#%',
4158 'prev_text' => __('&laquo; Previous', 'mxchat'),
4159 'next_text' => __('Next &raquo;', 'mxchat'),
4160 'total' => $total_pages,
4161 'current' => $current_page,
4162 'type' => 'plain',
4163 ));
4164 } else {
4165 $page_links = '';
4166 }
4167
4168 // ================================
4169 // PROCESSING STATUS RETRIEVAL
4170 // ================================
4171
4172 // Retrieve processing statuses using queue-based method
4173 $processing_statuses = $knowledge_manager->mxchat_get_processing_statuses();
4174 $pdf_status = $processing_statuses['pdf_status'];
4175 $sitemap_status = $processing_statuses['sitemap_status'];
4176 $is_processing = $processing_statuses['is_processing'];
4177
4178 //error_log('=== DEBUG: mxchat_create_prompts_page data preparation completed ===');
4179
4180 // ================================
4181 // RENDER PAGE WITH NEW SIDEBAR LAYOUT
4182 // ================================
4183
4184 // Include the new knowledge page template
4185 require_once plugin_dir_path(__FILE__) . 'admin-knowledge-page.php';
4186
4187 // Package all page data for the render function
4188 $page_data = array(
4189 'prompts' => $prompts,
4190 'total_records' => $total_records,
4191 'total_pages' => $total_pages,
4192 'current_page' => $current_page,
4193 'per_page' => $per_page,
4194 'page_links' => $page_links,
4195 'search_query' => $search_query,
4196 'content_type_filter' => $content_type_filter,
4197 'data_source' => $data_source,
4198 'use_pinecone' => $use_pinecone,
4199 'use_vectorstore' => $use_vectorstore,
4200 'multibot_active' => $multibot_active,
4201 'current_bot_id' => $current_bot_id,
4202 'pdf_status' => $pdf_status,
4203 'sitemap_status' => $sitemap_status,
4204 'is_processing' => $is_processing,
4205 'total_in_database' => $total_in_database ?? 0,
4206 'showing_recent_only' => $showing_recent_only ?? false,
4207 );
4208
4209 // Render the new sidebar-based page
4210 mxchat_render_knowledge_page($this, $knowledge_manager, $page_data);
4211 }
4212
4213 /**
4214 * Get bot-specific Pinecone configuration
4215 * Used in the knowledge retrieval functions
4216 */
4217 private function get_bot_pinecone_config($bot_id = 'default') {
4218 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4219
4220 // If default bot or multi-bot add-on not active, use default Pinecone config
4221 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4222 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4223 $addon_options = get_option('mxchat_pinecone_addon_options', array());
4224 $config = array(
4225 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4226 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4227 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4228 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4229 );
4230 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4231 return $config;
4232 }
4233
4234 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4235
4236 // Hook for multi-bot add-on to provide bot-specific Pinecone config
4237 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4238
4239 if (!empty($bot_pinecone_config)) {
4240 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4241 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4242 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4243 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4244 } else {
4245 //error_log("MXCHAT DEBUG: Filter returned empty config!");
4246 }
4247
4248 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4249 }
4250
4251 public function mxchat_delete_chat_history() {
4252 if (!current_user_can('manage_options')) {
4253 echo wp_json_encode(['error' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4254 wp_die();
4255 }
4256 check_ajax_referer('mxchat_delete_chat_history', 'security');
4257
4258 if (!isset($_POST['delete_session_ids']) || !is_array($_POST['delete_session_ids'])) {
4259 echo wp_json_encode(['error' => esc_html__('No chat sessions selected for deletion.', 'mxchat')]);
4260 wp_die();
4261 }
4262
4263 global $wpdb;
4264 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4265 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
4266 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
4267
4268 // When true, any lead attached to these sessions is fully wiped (all their sessions,
4269 // across the whole table). Default false: the chat rows go away but the lead is
4270 // preserved as a separate "chat deleted" lead in the Leads tab.
4271 $also_delete_lead = !empty($_POST['also_delete_lead']) && $_POST['also_delete_lead'] !== 'false';
4272
4273 $deleted_count = 0;
4274 $preserved_as_deleted_leads = 0;
4275 $emails_to_fully_wipe = [];
4276
4277 foreach ((array) $_POST['delete_session_ids'] as $session_id) {
4278 $session_id_sanitized = MxChat_Utils::sanitize_session_id($session_id);
4279 if ($session_id_sanitized === '') {
4280 continue;
4281 }
4282
4283 // Capture the lead info attached to this session *before* we delete the rows.
4284 $lead_row = $wpdb->get_row($wpdb->prepare(
4285 "SELECT user_email, user_name, MAX(timestamp) AS last_ts
4286 FROM {$table_name}
4287 WHERE session_id = %s AND user_email IS NOT NULL AND user_email != ''
4288 GROUP BY user_email, user_name
4289 ORDER BY last_ts DESC LIMIT 1",
4290 $session_id_sanitized
4291 ));
4292
4293 wp_cache_delete('chat_session_' . $session_id_sanitized, 'mxchat_chat_sessions');
4294 $wpdb->delete($table_name, ['session_id' => $session_id_sanitized]);
4295
4296 if ($has_translations) {
4297 $wpdb->delete($translations_table, ['session_id' => $session_id_sanitized]);
4298 }
4299
4300 // Read the consent record BEFORE delete_session() destroys the row —
4301 // a preserved "chat deleted" lead must keep its consent proof (b062c4).
4302 $consent_to_preserve = null;
4303 if ($lead_row && !empty($lead_row->user_email) && class_exists('MxChat_Session_Store')) {
4304 $consent_state = MxChat_Session_Store::get($session_id_sanitized, 'consent', '');
4305 if ($consent_state !== '' && $consent_state !== false) {
4306 $consent_to_preserve = [
4307 'given' => (string) $consent_state,
4308 'label' => (string) MxChat_Session_Store::get($session_id_sanitized, 'consent_label', ''),
4309 'at' => (string) MxChat_Session_Store::get($session_id_sanitized, 'consent_at', ''),
4310 ];
4311 }
4312 }
4313
4314 delete_option('mxchat_history_' . $session_id_sanitized);
4315 delete_option('mxchat_agent_name_' . $session_id_sanitized);
4316 if (class_exists('MxChat_Session_Store')) {
4317 MxChat_Session_Store::delete_session($session_id_sanitized); // b64b77
4318 }
4319
4320 if ($lead_row && !empty($lead_row->user_email)) {
4321 if ($also_delete_lead) {
4322 // Full-wipe requested — queue the email so that all their sessions and
4323 // related options get swept below. Also clear this session's pre-chat
4324 // capture options (they're no longer meaningful).
4325 $emails_to_fully_wipe[strtolower($lead_row->user_email)] = $lead_row->user_email;
4326 delete_option('mxchat_email_' . $session_id_sanitized);
4327 delete_option('mxchat_name_' . $session_id_sanitized);
4328 } else {
4329 // Preserve the lead in a "Chat deleted" state via distinct option keys so
4330 // they stay out of the orphan bucket (orphan = pre-chat form dropoff).
4331 update_option('mxchat_lead_del_email_' . $session_id_sanitized, $lead_row->user_email, false);
4332 if (!empty($lead_row->user_name)) {
4333 update_option('mxchat_lead_del_name_' . $session_id_sanitized, $lead_row->user_name, false);
4334 }
4335 if (!empty($lead_row->last_ts)) {
4336 update_option('mxchat_lead_del_ts_' . $session_id_sanitized, $lead_row->last_ts, false);
4337 }
4338 if ($consent_to_preserve !== null) {
4339 update_option('mxchat_lead_del_consent_' . $session_id_sanitized, $consent_to_preserve['given'], false);
4340 update_option('mxchat_lead_del_consent_label_' . $session_id_sanitized, $consent_to_preserve['label'], false);
4341 update_option('mxchat_lead_del_consent_at_' . $session_id_sanitized, $consent_to_preserve['at'], false);
4342 }
4343 // Clean up pre-chat capture options for this session — chat_deleted supersedes.
4344 delete_option('mxchat_email_' . $session_id_sanitized);
4345 delete_option('mxchat_name_' . $session_id_sanitized);
4346 $preserved_as_deleted_leads++;
4347 }
4348 } else {
4349 // No lead attached — nothing to preserve. Clean up any orphan options anyway.
4350 delete_option('mxchat_email_' . $session_id_sanitized);
4351 delete_option('mxchat_name_' . $session_id_sanitized);
4352 }
4353
4354 $deleted_count++;
4355 }
4356
4357 // Opt-in full-lead wipe: sweep every remaining row + every option key (including
4358 // chat_deleted preservation) for each affected email. Reuses the same internal
4359 // helper as the Leads-tab Delete button for consistency.
4360 if (!empty($emails_to_fully_wipe)) {
4361 self::mxchat_wipe_leads_by_email(array_values($emails_to_fully_wipe));
4362 }
4363
4364 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
4365
4366 echo wp_json_encode([
4367 'success' => sprintf(
4368 esc_html__('%d chat session(s) have been deleted.', 'mxchat'),
4369 $deleted_count
4370 ),
4371 'preserved_as_deleted_leads' => $preserved_as_deleted_leads,
4372 'leads_fully_wiped' => count($emails_to_fully_wipe),
4373 ]);
4374 wp_die();
4375 }
4376
4377 /**
4378 * Format transcript message content with markdown processing
4379 * Converts markdown links, bold, italic, code blocks, and plain URLs to HTML
4380 */
4381 private function format_transcript_message($text) {
4382 if (empty($text)) {
4383 return '';
4384 }
4385
4386 // Normalize line endings and clean up excessive whitespace
4387 $text = str_replace("\r\n", "\n", $text);
4388 $text = str_replace("\r", "\n", $text);
4389
4390 // Clean up existing <br> tags that may have been saved (legacy data)
4391 // Convert <br>, <br/>, <br /> back to newlines for consistent processing
4392 $text = preg_replace('/<br\s*\/?>\s*/i', "\n", $text);
4393
4394 // Collapse 3+ consecutive newlines to just 2 (paragraph break)
4395 $text = preg_replace('/\n{3,}/', "\n\n", $text);
4396
4397 // Process markdown headers (# Header)
4398 $text = preg_replace_callback('/^(#{1,6})\s+(.+)$/m', function($matches) {
4399 $level = strlen($matches[1]);
4400 $content = esc_html(trim($matches[2]));
4401 return "<h{$level}>{$content}</h{$level}>";
4402 }, $text);
4403
4404 // Process code blocks with triple backticks
4405 $text = preg_replace_callback('/```(\w+)?\n?([\s\S]*?)```/', function($matches) {
4406 $language = !empty($matches[1]) ? ' class="language-' . esc_attr($matches[1]) . '"' : '';
4407 $code = esc_html($matches[2]);
4408 return "<pre><code{$language}>{$code}</code></pre>";
4409 }, $text);
4410
4411 // Process inline code with single backticks
4412 $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
4413
4414 // Process bold text **text** or __text__
4415 $text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
4416 $text = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $text);
4417
4418 // Process italic text *text* or _text_ (but not if part of URL)
4419 $text = preg_replace('/(?<![*_\w])\*([^*]+)\*(?![*\w])/', '<em>$1</em>', $text);
4420 $text = preg_replace('/(?<![*_\w])_([^_]+)_(?![*\w])/', '<em>$1</em>', $text);
4421
4422 // Process markdown links [text](url)
4423 $text = preg_replace_callback('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', function($matches) {
4424 $link_text = esc_html($matches[1]);
4425 $url = esc_url($matches[2]);
4426 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$link_text}</a>";
4427 }, $text);
4428
4429 // Process citation-style brackets [URL]
4430 $text = preg_replace_callback('/\[(https?:\/\/[^\]]+)\]/', function($matches) {
4431 $url = esc_url($matches[1]);
4432 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$url}</a>";
4433 }, $text);
4434
4435 // Process standalone URLs (not already in links or img src)
4436 $text = preg_replace_callback(
4437 '/(?<!href="|src="|">)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/',
4438 function($matches) {
4439 $url = esc_url($matches[1]);
4440 // Truncate display URL if too long
4441 $display = strlen($matches[1]) > 50 ? substr($matches[1], 0, 47) . '...' : $matches[1];
4442 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$display}</a>";
4443 },
4444 $text
4445 );
4446
4447 // Process mailto links
4448 $text = preg_replace_callback('/\[([^\]]+)\]\((mailto:[^)]+)\)/', function($matches) {
4449 $link_text = esc_html($matches[1]);
4450 $mailto = esc_url($matches[2]);
4451 return "<a href=\"{$mailto}\">{$link_text}</a>";
4452 }, $text);
4453
4454 // Convert paragraphs: split by double newlines, wrap in <p> tags
4455 // This creates proper paragraph structure instead of excessive <br> tags
4456 $paragraphs = preg_split('/\n\n+/', $text);
4457
4458 // Filter out empty paragraphs but preserve content like "0"
4459 $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), function($p) {
4460 return $p !== '';
4461 }));
4462
4463 if (empty($paragraphs)) {
4464 // No content after filtering
4465 return '';
4466 } elseif (count($paragraphs) > 1) {
4467 // Multiple paragraphs - wrap each in <p> tags, convert single newlines to <br>
4468 $formatted_paragraphs = array_map(function($p) {
4469 return nl2br($p);
4470 }, $paragraphs);
4471 $text = '<p>' . implode('</p><p>', $formatted_paragraphs) . '</p>';
4472 } else {
4473 // Single paragraph - just convert newlines to <br>
4474 $text = nl2br($paragraphs[0]);
4475 }
4476
4477 return $text;
4478 }
4479
4480 /**
4481 * AJAX handler to fetch RAG context for a specific message
4482 * Used by the transcript viewer to show retrieved documents
4483 */
4484 public function mxchat_get_rag_context() {
4485 // Check permissions
4486 if (!current_user_can('manage_options')) {
4487 wp_send_json_error(['message' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4488 wp_die();
4489 }
4490
4491 // Validate message ID
4492 if (!isset($_POST['message_id']) || empty($_POST['message_id'])) {
4493 wp_send_json_error(['message' => esc_html__('Message ID is required.', 'mxchat')]);
4494 wp_die();
4495 }
4496
4497 $message_id = absint($_POST['message_id']);
4498
4499 global $wpdb;
4500 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4501
4502 // Fetch the RAG context for this message
4503 $result = $wpdb->get_row($wpdb->prepare(
4504 "SELECT rag_context FROM {$table_name} WHERE id = %d",
4505 $message_id
4506 ));
4507
4508 if (!$result || empty($result->rag_context)) {
4509 wp_send_json_error(['message' => esc_html__('No RAG context found for this message.', 'mxchat')]);
4510 wp_die();
4511 }
4512
4513 // Decode the JSON data
4514 $rag_context = json_decode($result->rag_context, true);
4515
4516 if (json_last_error() !== JSON_ERROR_NONE) {
4517 wp_send_json_error(['message' => esc_html__('Invalid RAG context data.', 'mxchat')]);
4518 wp_die();
4519 }
4520
4521 wp_send_json_success($rag_context);
4522 wp_die();
4523 }
4524
4525 public function display_admin_notices() {
4526 // Check if we're on a MXChat admin page
4527 $screen = get_current_screen();
4528 if (!$screen || strpos($screen->base, 'mxchat') === false) {
4529 return;
4530 }
4531
4532 $dismiss_button = '<button type="button" class="notice-dismiss"><span class="screen-reader-text">' . esc_html__('Dismiss this notice.', 'mxchat') . '</span></button>';
4533
4534 // Check for error notices
4535 $error_notice = get_transient('mxchat_admin_notice_error');
4536 if ($error_notice) {
4537 echo '<div class="notice notice-error is-dismissible"><p>' . wp_kses_post($error_notice) . '</p>' . $dismiss_button . '</div>';
4538 delete_transient('mxchat_admin_notice_error');
4539 }
4540
4541 // Check for success notices
4542 $success_notice = get_transient('mxchat_admin_notice_success');
4543 if ($success_notice) {
4544 echo '<div class="notice notice-success is-dismissible"><p>' . wp_kses_post($success_notice) . '</p>' . $dismiss_button . '</div>';
4545 delete_transient('mxchat_admin_notice_success');
4546 }
4547
4548 // Check for info notices
4549 $info_notice = get_transient('mxchat_admin_notice_info');
4550 if ($info_notice) {
4551 echo '<div class="notice notice-info is-dismissible"><p>' . wp_kses_post($info_notice) . '</p>' . $dismiss_button . '</div>';
4552 delete_transient('mxchat_admin_notice_info');
4553 }
4554 }
4555
4556
4557 public function mxchat_create_activation_page() {
4558 // Include the new Pro & Extensions page template
4559 require_once plugin_dir_path(__FILE__) . 'admin-pro-page.php';
4560
4561 // Get addons configuration from the MxChat_Addons class
4562 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
4563 $addons_instance = new MxChat_Addons();
4564 $addons_config = $addons_instance->get_addons_config();
4565
4566 // Render the consolidated Pro & Extensions page
4567 mxchat_render_pro_page($this, $addons_config);
4568 }
4569
4570 /**
4571 * Check if current activation is linked to a domain
4572 * This checks YOUR website's database, not the user's local database
4573 */
4574 public function is_current_activation_linked($domain) {
4575 $license_key = get_option('mxchat_activation_key');
4576 $email = get_option('mxchat_pro_email');
4577
4578 if (empty($license_key) || empty($email)) {
4579 return false;
4580 }
4581
4582 // Check with YOUR website's API
4583 $response = wp_remote_post('https://mxchat.ai/mxchat-api/check-domain', array(
4584 'body' => array(
4585 'license_key' => $license_key,
4586 'email' => $email,
4587 'domain' => $domain
4588 ),
4589 'timeout' => 10,
4590 'sslverify' => false
4591 ));
4592
4593 if (is_wp_error($response)) {
4594 return false;
4595 }
4596
4597 $body = json_decode(wp_remote_retrieve_body($response), true);
4598 return isset($body['success']) && $body['success'] && isset($body['data']['linked']) && $body['data']['linked'];
4599 }
4600
4601
4602 public function mxchat_actions_page_html() {
4603 if (!current_user_can('manage_options')) {
4604 return;
4605 }
4606
4607 global $wpdb;
4608 $table_name = $wpdb->prefix . 'mxchat_intents';
4609
4610 // Get stats for dashboard
4611 $total_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
4612 $enabled_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE enabled = 1");
4613 $disabled_actions = $total_actions - $enabled_actions;
4614
4615 // Get unique action types count
4616 $action_types_count = $wpdb->get_var("SELECT COUNT(DISTINCT callback_function) FROM $table_name");
4617
4618 // Get action type distribution
4619 $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");
4620 $available_callbacks = $this->mxchat_get_available_callbacks();
4621 $callback_groups = $this->mxchat_get_available_callbacks(true, true);
4622
4623 $action_type_distribution = array();
4624 foreach ($type_distribution_raw as $row) {
4625 $label = isset($available_callbacks[$row->callback_function]['label'])
4626 ? $available_callbacks[$row->callback_function]['label']
4627 : $row->callback_function;
4628 $action_type_distribution[$label] = $row->count;
4629 }
4630
4631 // Native function-calling (AI Tools) data — plan-mxchat-20260617-a41dee.
4632 // The AI Tools checklist reads from MxChat_Tool_Registry, the SAME single
4633 // source the chat-time function-calling loop reads, so the two never drift.
4634 if (!class_exists('MxChat_Tool_Registry')) {
4635 require_once plugin_dir_path(__FILE__) . 'class-mxchat-tool-registry.php';
4636 }
4637 if (!class_exists('MxChat_Model_Catalog')) {
4638 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
4639 }
4640 $fc_options = get_option('mxchat_options', array());
4641 $fc_current_model = isset($fc_options['model']) ? $fc_options['model'] : 'gpt-5.6-sol';
4642 $fc_model_capable = class_exists('MxChat_Model_Catalog')
4643 ? MxChat_Model_Catalog::supports_tools($fc_current_model) : true;
4644 $active_tab = (isset($_GET['tab']) && $_GET['tab'] === 'ai-tools') ? 'ai-tools' : 'dashboard';
4645
4646 // Enrich each AI Tool with the dashicon its callback already uses on the
4647 // Trigger Phrases "Choose what it does" grid, so the AI Tools cards/modal
4648 // share the same iconography (plan 8bbf98 part 4). View-layer only — the
4649 // registry's model-facing data is untouched. Default admin-generic.
4650 $fc_tools = MxChat_Tool_Registry::available_tools();
4651 foreach ($fc_tools as &$fc_tool_ref) {
4652 $fc_cb_ref = $fc_tool_ref['callback'];
4653 $fc_tool_ref['icon'] = isset($available_callbacks[$fc_cb_ref]['icon'])
4654 ? $available_callbacks[$fc_cb_ref]['icon']
4655 : 'admin-generic';
4656 }
4657 unset($fc_tool_ref);
4658
4659 // Count of ACTIVE tools — drives the AI Tools sidebar nav badge, mirroring
4660 // $total_actions for Trigger Phrases. A tool in the list = active (plan
4661 // d450a7), so the badge shows the same number the list pane shows (plan 5f7409).
4662 $total_tools = count(array_filter($fc_tools, function ($t) {
4663 return !empty($t['enabled']);
4664 }));
4665
4666 // Brave-key dependency check (plan 183856): Web Search + Image Search run on
4667 // the Brave Search API. If either is enabled as a tool but no brave_api_key is
4668 // configured, surface a graceful "key not set" notice so the admin isn't met
4669 // with a silently no-firing tool.
4670 $fc_brave_missing = false;
4671 $brave_key = isset($fc_options['brave_api_key']) ? trim($fc_options['brave_api_key']) : '';
4672 if ($brave_key === '') {
4673 foreach ($fc_tools as $fc_t) {
4674 if (!empty($fc_t['enabled']) && isset($fc_t['requires_key']) && $fc_t['requires_key'] === 'brave_api_key') {
4675 $fc_brave_missing = true;
4676 break;
4677 }
4678 }
4679 }
4680
4681 // Prepare page data
4682 $page_data = array(
4683 'total_actions' => $total_actions,
4684 'enabled_actions' => $enabled_actions,
4685 'disabled_actions' => $disabled_actions,
4686 'action_types_count' => $action_types_count,
4687 'action_type_distribution' => $action_type_distribution,
4688 'available_callbacks' => $available_callbacks,
4689 'callback_groups' => $callback_groups,
4690 // AI Tools section
4691 'active_tab' => $active_tab,
4692 'fc_enabled' => MxChat_Tool_Registry::is_enabled(),
4693 'total_tools' => $total_tools,
4694 'fc_tools' => $fc_tools,
4695 'fc_current_model' => $fc_current_model,
4696 'fc_model_capable' => $fc_model_capable,
4697 'fc_brave_missing' => $fc_brave_missing,
4698 'fc_saved' => isset($_GET['mxchat_fc_saved']),
4699 );
4700
4701 // Include and render the new template
4702 require_once plugin_dir_path(__FILE__) . 'admin-actions-page.php';
4703 mxchat_render_actions_page($this, $page_data);
4704 }
4705
4706 /**
4707 * LEGACY HTML - Preserved below for reference, to be removed in future update
4708 */
4709 function mxchat_actions_page_legacy_html() {
4710 // This function is deprecated and no longer used
4711 // The new template is in includes/admin-actions-page.php
4712 ?>
4713 <div class="wrap mxchat-wrapper">
4714 <!-- Hero Section -->
4715 <div class="mxchat-hero">
4716 <h1 class="mxchat-main-title">
4717 <span class="mxchat-gradient-text">Actions</span> Manager
4718 </h1>
4719 <p class="mxchat-hero-subtitle">
4720 <?php esc_html_e('Create and manage custom actions to enhance your chatbot\'s capabilities.', 'mxchat'); ?>
4721 </p>
4722 </div>
4723
4724 <!-- Actions Header with Search and Filter -->
4725 <div class="mxchat-actions-header">
4726 <div class="mxchat-actions-filters">
4727 <form method="get" class="mxchat-search-form">
4728 <input type="hidden" name="page" value="mxchat-actions">
4729 <div class="mxchat-search-group">
4730 <span class="dashicons dashicons-search"></span>
4731 <input type="text" name="s" class="mxchat-search-input"
4732 placeholder="<?php esc_attr_e('Search Actions', 'mxchat'); ?>"
4733 value="<?php echo esc_attr($search_term); ?>">
4734 </div>
4735 <select name="callback_filter" class="mxchat-action-filter">
4736 <option value=""><?php esc_html_e('All Action Types', 'mxchat'); ?></option>
4737 <?php foreach ($available_callbacks as $function => $callback_data) :
4738 $label = $callback_data['label']; ?>
4739 <option value="<?php echo esc_attr($function); ?>"
4740 <?php selected($callback_filter, $function); ?>>
4741 <?php echo esc_html($label); ?>
4742 </option>
4743 <?php endforeach; ?>
4744 </select>
4745 <button type="submit" class="mxchat-button-secondary">
4746 <?php esc_html_e('Filter', 'mxchat'); ?>
4747 </button>
4748 </form>
4749 </div>
4750 <div class="mxchat-actions-controls">
4751 <button type="button" id="mxchat-add-action-btn" class="mxchat-button-primary">
4752 <span class="dashicons dashicons-plus-alt"></span>
4753 <?php esc_html_e('Add New Action', 'mxchat'); ?>
4754 </button>
4755 </div>
4756 </div>
4757
4758 <!-- Actions Grid Layout - All actions in a single grid -->
4759 <div class="mxchat-actions-grid">
4760 <div class="mxchat-cards-container">
4761 <?php if (!empty($actions)) : ?>
4762 <?php foreach ($actions as $action) :
4763 $callback_function = $action->callback_function;
4764 $callback_label = isset($available_callbacks[$callback_function]['label'])
4765 ? $available_callbacks[$callback_function]['label']
4766 : $callback_function;
4767 $threshold_value = isset($action->similarity_threshold)
4768 ? round($action->similarity_threshold * 100)
4769 : 85;
4770
4771 // Check if this is a form action
4772 $is_form_action = strpos($action->intent_label, 'Form ') === 0;
4773
4774 // Get action status (enabled/disabled) - default to true if column doesn't exist
4775 $is_enabled = isset($action->enabled) ? (bool)$action->enabled : true;
4776
4777 // Get enabled bots for display
4778 $enabled_bots = [];
4779 if (isset($action->enabled_bots) && !empty($action->enabled_bots)) {
4780 $enabled_bots = json_decode($action->enabled_bots, true);
4781 if (!is_array($enabled_bots)) {
4782 $enabled_bots = ['default'];
4783 }
4784 } else {
4785 $enabled_bots = ['default']; // Backward compatibility
4786 }
4787 ?>
4788 <div class="mxchat-action-card <?php echo $is_form_action ? 'mxchat-form-action' : ''; ?>">
4789 <div class="mxchat-card-header">
4790 <div class="mxchat-card-title"><?php echo esc_html($action->intent_label); ?></div>
4791 <div class="mxchat-card-toggle">
4792 <label class="mxchat-switch">
4793 <input type="checkbox" class="mxchat-action-toggle"
4794 data-action-id="<?php echo esc_attr($action->id); ?>"
4795 <?php checked($is_enabled); ?>>
4796 <span class="mxchat-slider round"></span>
4797 </label>
4798 </div>
4799 </div>
4800
4801 <div class="mxchat-card-body">
4802 <div class="mxchat-card-description">
4803 <strong><?php esc_html_e('Type:', 'mxchat'); ?></strong>
4804 <?php echo esc_html($callback_label); ?>
4805 </div>
4806
4807 <div class="mxchat-card-phrases">
4808 <strong><?php esc_html_e('Trigger phrases:', 'mxchat'); ?></strong>
4809 <div class="mxchat-phrases-preview">
4810 <?php
4811 // Check if the helper function exists, otherwise use a simple substring
4812 if (method_exists($this, 'get_trimmed_phrases')) {
4813 echo esc_html($this->get_trimmed_phrases($action->phrases));
4814 } else {
4815 echo esc_html(strlen($action->phrases) > 100 ?
4816 substr($action->phrases, 0, 97) . '...' :
4817 $action->phrases);
4818 }
4819 ?>
4820 </div>
4821 </div>
4822
4823 <div class="mxchat-threshold-control">
4824 <div class="mxchat-threshold-label">
4825 <?php esc_html_e('Similarity Threshold:', 'mxchat'); ?>
4826 <span class="mxchat-threshold-value"><?php echo esc_html($threshold_value); ?>%</span>
4827 </div>
4828 </div>
4829
4830 <!-- Bot Availability Display (Read-only) -->
4831 <div class="mxchat-card-bots">
4832 <strong><?php esc_html_e('Assigned bots:', 'mxchat'); ?></strong>
4833 <div class="mxchat-bot-badges">
4834 <?php
4835 foreach ($enabled_bots as $bot_id) {
4836 if ($bot_id === 'default') {
4837 echo '<span class="mxchat-bot-badge">' . esc_html__('Default Bot', 'mxchat') . '</span>';
4838 } else {
4839 // Try to get bot name from multi-bot manager
4840 if (class_exists('MxChat_Multi_Bot_Core_Manager')) {
4841 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
4842 $available_bots = $multi_bot_manager->get_available_bots();
4843 $bot_name = isset($available_bots[$bot_id]) ? $available_bots[$bot_id] : $bot_id;
4844 echo '<span class="mxchat-bot-badge">' . esc_html($bot_name) . '</span>';
4845 } else {
4846 echo '<span class="mxchat-bot-badge">' . esc_html($bot_id) . '</span>';
4847 }
4848 }
4849 }
4850 ?>
4851 </div>
4852 </div>
4853 </div>
4854
4855 <div class="mxchat-card-footer">
4856 <?php
4857 // Check if it's a form action
4858 $is_form_action = preg_match('/Form (\d+)/', $action->intent_label, $form_matches);
4859
4860 // Check if it's a recommendation flow action
4861 $is_flow_action = preg_match('/Recommendation Flow (\d+)/', $action->intent_label, $flow_matches);
4862
4863 if ($is_form_action) {
4864 $form_id = isset($form_matches[1]) ? $form_matches[1] : '';
4865 ?>
4866 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-forms&action=edit&form_id=' . $form_id)); ?>"
4867 class="mxchat-button-primary">
4868 <span class="dashicons dashicons-feedback"></span>
4869 <?php esc_html_e('Edit Form', 'mxchat'); ?>
4870 </a>
4871 <?php } elseif ($is_flow_action) {
4872 ?>
4873 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-smart-recommender')); ?>"
4874 class="mxchat-button-primary">
4875 <span class="dashicons dashicons-list-view"></span>
4876 <?php esc_html_e('Manage Flows', 'mxchat'); ?>
4877 </a>
4878 <?php } else { ?>
4879 <button type="button"
4880 class="mxchat-button-secondary mxchat-edit-button"
4881 data-action-id="<?php echo esc_attr($action->id); ?>"
4882 data-phrases="<?php echo esc_attr($action->phrases); ?>"
4883 data-label="<?php echo esc_attr($action->intent_label); ?>"
4884 data-threshold="<?php echo esc_attr(round($action->similarity_threshold * 100)); ?>"
4885 data-callback-function="<?php echo esc_attr($action->callback_function); ?>"
4886 data-enabled-bots="<?php echo esc_attr(json_encode($enabled_bots)); ?>">
4887 <span class="dashicons dashicons-edit"></span>
4888 <?php esc_html_e('Edit', 'mxchat'); ?>
4889 </button>
4890 <form method="post"
4891 action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
4892 class="mxchat-delete-form"
4893 onsubmit="return confirm('<?php esc_attr_e('Are you sure you want to delete this action?', 'mxchat'); ?>');">
4894 <?php wp_nonce_field('mxchat_delete_intent_nonce'); ?>
4895 <input type="hidden" name="action" value="mxchat_delete_intent">
4896 <input type="hidden" name="intent_id" value="<?php echo esc_attr($action->id); ?>">
4897 <button type="submit" class="mxchat-button-text mxchat-delete-button">
4898 <span class="dashicons dashicons-trash"></span>
4899 <?php esc_html_e('Delete', 'mxchat'); ?>
4900 </button>
4901 </form>
4902 <?php } ?>
4903 </div>
4904 </div>
4905 <?php endforeach; ?>
4906 <?php else : ?>
4907 <!-- If no actions found -->
4908 <div class="mxchat-no-actions">
4909 <div class="mxchat-empty-state">
4910 <span class="dashicons dashicons-format-chat"></span>
4911 <h2><?php esc_html_e('No actions found', 'mxchat'); ?></h2>
4912 <p><?php esc_html_e('Get started by creating your first action to enhance your chatbot.', 'mxchat'); ?></p>
4913 <button type="button" id="mxchat-create-first-action" class="mxchat-button-primary">
4914 <?php esc_html_e('Create Your First Action', 'mxchat'); ?>
4915 </button>
4916 </div>
4917 </div>
4918 <?php endif; ?>
4919 </div>
4920 </div>
4921
4922 <?php if ($total_pages > 1) : ?>
4923 <div class="mxchat-pagination">
4924 <?php
4925 echo paginate_links(array(
4926 'base' => add_query_arg('paged', '%#%'),
4927 'format' => '',
4928 'prev_text' => __('&laquo; Previous', 'mxchat'),
4929 'next_text' => __('Next &raquo;', 'mxchat'),
4930 'total' => $total_pages,
4931 'current' => $page
4932 ));
4933 ?>
4934 </div>
4935 <?php endif; ?>
4936
4937 <!-- Add/Edit Action Modal with Step-Based Approach -->
4938 <!-- Complete Modal HTML with Defined Groups Variable -->
4939 <div id="mxchat-action-modal" class="mxchat-modal" style="display: none;">
4940 <div class="mxchat-modal-content">
4941 <span class="mxchat-modal-close">&times;</span>
4942
4943 <form id="mxchat-action-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
4944 <!-- Dynamic nonce field -->
4945 <div id="action-nonce-container">
4946 <?php wp_nonce_field('mxchat_add_intent_nonce', 'add_intent_nonce'); ?>
4947 </div>
4948 <input type="hidden" name="action" id="form_action_type" value="mxchat_add_intent">
4949 <input type="hidden" name="intent_id" id="edit_action_id" value="">
4950 <input type="hidden" name="callback_function" id="callback_function" value="">
4951
4952 <!-- Step 1: Action Type Selection -->
4953 <div id="mxchat-action-step-1" class="mxchat-action-step active">
4954 <div class="mxchat-step-indicator">
4955 <div class="mxchat-step-number">1</div>
4956 <div class="mxchat-step-title"><?php esc_html_e('Select Action Type', 'mxchat'); ?></div>
4957 </div>
4958
4959 <div id="mxchat-action-type-selector" class="mxchat-action-type-selector">
4960 <div class="mxchat-action-type-search">
4961 <span class="dashicons dashicons-search"></span>
4962 <input type="text" id="action-type-search" placeholder="<?php esc_attr_e('Search action types...', 'mxchat'); ?>" class="mxchat-action-type-search-input">
4963 </div>
4964
4965 <?php
4966 // Get the callbacks - IMPORTANT: Define the $groups variable here
4967 $groups = $this->mxchat_get_available_callbacks(true, true);
4968 ?>
4969
4970 <div class="mxchat-action-type-categories">
4971 <button type="button" class="mxchat-category-button active" data-category="all"><?php esc_html_e('All', 'mxchat'); ?></button>
4972 <?php
4973 // Get unique categories from the defined groups
4974 foreach ($groups as $group_label => $group_callbacks) :
4975 $category_slug = sanitize_title($group_label);
4976 ?>
4977 <button type="button" class="mxchat-category-button" data-category="<?php echo esc_attr($category_slug); ?>"><?php echo esc_html($group_label); ?></button>
4978 <?php endforeach; ?>
4979 </div>
4980
4981 <div class="mxchat-action-types-grid">
4982 <?php
4983 // Generate action cards from available callbacks
4984 foreach ($groups as $group_label => $group_callbacks) :
4985 $category_slug = sanitize_title($group_label);
4986
4987 foreach ($group_callbacks as $function => $data) :
4988 $label = $data['label'];
4989 $icon = isset($data['icon']) ? $data['icon'] : 'admin-generic';
4990 $description = isset($data['description']) ? $data['description'] : '';
4991 $is_addon = isset($data['addon']) && $data['addon'] !== false;
4992 $addon_name = isset($data['addon_name']) ? $data['addon_name'] : '';
4993 $is_installed = isset($data['installed']) ? $data['installed'] : true;
4994 $is_promo = !empty($data['addon_promo']) && !$is_installed;
4995
4996 // Determine card status and styling
4997 $card_class = 'mxchat-action-type-card';
4998 $icon_class = 'mxchat-action-type-icon';
4999 $status_badge = '';
5000
5001 if ($is_promo) {
5002 // Promotional card — not selectable, just informational
5003 $card_class .= ' not-installed mxchat-promo-card';
5004 $status_badge = '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
5005 } elseif ($is_addon && !$is_installed) {
5006 // Add-on not installed
5007 $card_class .= ' not-installed';
5008 $status_badge .= '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
5009 }
5010
5011 // Default description if none provided
5012 if (empty($description)) {
5013 $description = sprintf(
5014 esc_html__('Use the %s action in your chatbot', 'mxchat'),
5015 $label
5016 );
5017 }
5018 ?>
5019 <div class="<?php echo esc_attr($card_class); ?>"
5020 data-category="<?php echo esc_attr($category_slug); ?>"
5021 <?php if (!$is_promo) : ?>
5022 data-value="<?php echo esc_attr($function); ?>"
5023 data-label="<?php echo esc_attr($label); ?>"
5024 <?php endif; ?>
5025 data-pro="false"
5026 data-addon="<?php echo esc_attr($is_addon ? $data['addon'] : ''); ?>"
5027 data-installed="<?php echo $is_installed ? 'true' : 'false'; ?>"
5028 <?php if ($is_promo) : ?>data-promo="true"<?php endif; ?>>
5029 <div class="<?php echo esc_attr($icon_class); ?>">
5030 <span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span>
5031 </div>
5032 <div class="mxchat-action-type-info">
5033 <h4><?php echo esc_html($label); ?></h4>
5034 <p><?php echo esc_html($description); ?></p>
5035 <?php if (!empty($status_badge)) : ?>
5036 <?php echo $status_badge; ?>
5037 <?php endif; ?>
5038
5039 <?php if ($is_promo || ($is_addon && !$is_installed)) : ?>
5040 <div class="mxchat-addon-info">
5041 <?php echo esc_html(sprintf(
5042 __('Requires %s', 'mxchat'),
5043 $addon_name
5044 )); ?>
5045 — <a href="https://mxchat.ai/" target="_blank"><?php esc_html_e('Get Add-on', 'mxchat'); ?></a>
5046 </div>
5047 <?php endif; ?>
5048 </div>
5049 </div>
5050 <?php endforeach;
5051 endforeach; ?>
5052 </div>
5053 </div>
5054
5055 <div class="mxchat-modal-actions">
5056 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
5057 <?php esc_html_e('Cancel', 'mxchat'); ?>
5058 </button>
5059 </div>
5060 </div>
5061
5062 <!-- Step 2: Action Configuration -->
5063 <div id="mxchat-action-step-2" class="mxchat-action-step">
5064 <div class="mxchat-step-indicator">
5065 <div class="mxchat-step-number">2</div>
5066 <div class="mxchat-step-title"><?php esc_html_e('Configure Action', 'mxchat'); ?></div>
5067 </div>
5068
5069 <div class="mxchat-selected-action">
5070 <button type="button" class="mxchat-back-button" id="mxchat-back-to-step-1">
5071 <span class="dashicons dashicons-arrow-left-alt"></span>
5072 <?php esc_html_e('Back to Action Types', 'mxchat'); ?>
5073 </button>
5074 <div class="mxchat-selected-action-info">
5075 <div id="selected-action-icon" class="mxchat-action-type-icon">
5076 <span class="dashicons dashicons-admin-generic"></span>
5077 </div>
5078 <div class="mxchat-selected-action-details">
5079 <h3 id="selected-action-title"><?php esc_html_e('Selected Action', 'mxchat'); ?></h3>
5080 <p id="selected-action-description"><?php esc_html_e('Configure this action for your chatbot', 'mxchat'); ?></p>
5081 </div>
5082 </div>
5083 </div>
5084
5085 <div class="mxchat-form-group">
5086 <label for="intent_label">
5087 <?php esc_html_e('Action Label (For your reference only)', 'mxchat'); ?>
5088 </label>
5089 <input name="intent_label" type="text" id="intent_label" required
5090 class="mxchat-intent-input"
5091 placeholder="<?php esc_attr_e('Example: Newsletter Signup', 'mxchat'); ?>">
5092 </div>
5093
5094 <div class="mxchat-form-group">
5095 <label for="phrases">
5096 <?php esc_html_e('Trigger Phrases (comma-separated)', 'mxchat'); ?>
5097 </label>
5098 <textarea name="phrases" id="action_phrases" rows="5" required
5099 class="mxchat-intent-textarea"
5100 placeholder="<?php esc_attr_e('Example: sign me up, subscribe me, I want to join, add me to the newsletter', 'mxchat'); ?>"></textarea>
5101 </div>
5102
5103 <div class="mxchat-form-group">
5104 <label for="similarity_threshold">
5105 <?php esc_html_e('Similarity Threshold', 'mxchat'); ?>
5106 <span class="mxchat-threshold-value-display">85%</span>
5107 </label>
5108 <div class="mxchat-slider-group modal-slider">
5109 <input type="range"
5110 name="similarity_threshold"
5111 id="similarity_threshold"
5112 min="10"
5113 max="95"
5114 value="85"
5115 class="mxchat-intent-slider"
5116 oninput="document.querySelector('.mxchat-threshold-value-display').textContent = this.value + '%'">
5117 </div>
5118 <div class="mxchat-threshold-hint">
5119 <?php esc_html_e('Lower values (10-30) make the action trigger more easily. Higher values (70-95) require more exact matches.', 'mxchat'); ?>
5120 </div>
5121 </div>
5122
5123 <!-- Bot Selection Section -->
5124 <div class="mxchat-form-group">
5125 <label for="enabled_bots">
5126 <?php esc_html_e('Which bots should we enable this action for?', 'mxchat'); ?>
5127 </label>
5128 <div class="mxchat-bot-selector">
5129 <div class="mxchat-bot-option">
5130 <label class="mxchat-checkbox-label">
5131 <input type="checkbox"
5132 name="enabled_bots[]"
5133 value="default"
5134 id="bot_default"
5135 checked="checked">
5136 <span class="mxchat-checkmark"></span>
5137 <?php esc_html_e('Default Bot', 'mxchat'); ?>
5138 </label>
5139 </div>
5140
5141 <?php if (class_exists('MxChat_Multi_Bot_Core_Manager')) :
5142 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
5143 $available_bots = $multi_bot_manager->get_available_bots();
5144
5145 foreach ($available_bots as $bot_id => $bot_name) :
5146 if ($bot_id === 'default') continue; // Skip default, already shown above
5147 ?>
5148 <div class="mxchat-bot-option">
5149 <label class="mxchat-checkbox-label">
5150 <input type="checkbox"
5151 name="enabled_bots[]"
5152 value="<?php echo esc_attr($bot_id); ?>"
5153 id="bot_<?php echo esc_attr($bot_id); ?>">
5154 <span class="mxchat-checkmark"></span>
5155 <?php echo esc_html($bot_name); ?>
5156 </label>
5157 </div>
5158 <?php
5159 endforeach;
5160 endif; ?>
5161 </div>
5162 </div>
5163
5164 <div class="mxchat-modal-actions">
5165 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
5166 <?php esc_html_e('Cancel', 'mxchat'); ?>
5167 </button>
5168 <button type="submit" class="mxchat-button-primary" id="mxchat-save-action-btn">
5169 <?php esc_html_e('Save Action', 'mxchat'); ?>
5170 </button>
5171 </div>
5172 </div>
5173 </form>
5174 </div>
5175 </div>
5176
5177 <div id="mxchat-action-loading" class="mxchat-action-loading" style="display: none;">
5178 <div class="mxchat-action-loading-spinner"></div>
5179 <div class="mxchat-action-loading-text">
5180 <?php esc_html_e('Saving action, please wait...', 'mxchat'); ?>
5181 </div>
5182 </div>
5183 </div><!-- .mxchat-wrapper -->
5184 <?php
5185 }
5186 private function get_trimmed_phrases($phrases, $max_length = 100) {
5187 if (strlen($phrases) <= $max_length) {
5188 return $phrases;
5189 }
5190
5191 $trimmed = substr($phrases, 0, $max_length);
5192 $last_comma = strrpos($trimmed, ',');
5193
5194 if ($last_comma !== false) {
5195 $trimmed = substr($trimmed, 0, $last_comma);
5196 }
5197
5198 return $trimmed . '...';
5199 }
5200 public function mxchat_add_enabled_column_to_intents() {
5201 global $wpdb;
5202 $table_name = $wpdb->prefix . 'mxchat_intents';
5203
5204 // Check if the column already exists
5205 $columns = $wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'enabled'");
5206
5207 if (empty($columns)) {
5208 // Add the column with default value of 1 (enabled)
5209 $wpdb->query("ALTER TABLE $table_name ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1");
5210 }
5211 }
5212
5213 public function mxchat_handle_delete_intent() {
5214 if ( ! current_user_can( 'manage_options' ) ) {
5215 wp_die( esc_html__('Unauthorized user', 'mxchat') );
5216 }
5217
5218 check_admin_referer('mxchat_delete_intent_nonce');
5219
5220 if (isset($_POST['intent_id'])) {
5221 global $wpdb;
5222 $table_name = $wpdb->prefix . 'mxchat_intents';
5223 $intent_id = intval($_POST['intent_id']);
5224
5225 $wpdb->delete($table_name, ['id' => $intent_id], ['%d']);
5226 }
5227
5228 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5229 exit;
5230 }
5231 public function mxchat_handle_edit_intent() {
5232 // Security checks (nonce and permissions)
5233 if (!current_user_can('manage_options')) {
5234 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5235 }
5236 check_admin_referer('mxchat_edit_intent');
5237
5238 // Get POST data
5239 $intent_id = isset($_POST['intent_id']) ? absint($_POST['intent_id']) : 0;
5240 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5241 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5242 $threshold_percentage = isset($_POST['similarity_threshold']) ? intval($_POST['similarity_threshold']) : 85;
5243 $similarity_threshold = min(95, max(10, $threshold_percentage)) / 100; // Convert to 0.10–0.95
5244
5245 // Handle enabled_bots
5246 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5247 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5248
5249 // Ensure default is always included for backward compatibility
5250 if (!in_array('default', $enabled_bots)) {
5251 $enabled_bots[] = 'default';
5252 }
5253
5254 $enabled_bots_json = json_encode($enabled_bots);
5255
5256 // Validate inputs
5257 if (!$intent_id || empty($intent_label) || empty($phrases_input)) {
5258 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5259 return;
5260 }
5261
5262 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5263 if (empty($phrases_array)) {
5264 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5265 return;
5266 }
5267
5268 // Generate embeddings with improved error handling
5269 $vectors = [];
5270 $failed_phrases = [];
5271
5272 foreach ($phrases_array as $phrase) {
5273 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5274 if (is_array($embedding_vector)) {
5275 $vectors[] = $embedding_vector;
5276 } else {
5277 $failed_phrases[] = $phrase;
5278 }
5279 }
5280
5281 if (!empty($failed_phrases)) {
5282 $this->handle_embedding_error(
5283 sprintf(
5284 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5285 implode(', ', $failed_phrases)
5286 )
5287 );
5288 return;
5289 }
5290
5291 if (empty($vectors)) {
5292 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5293 return;
5294 }
5295
5296 $combined_vector = $this->mxchat_average_vectors($vectors);
5297 $serialized_vector = maybe_serialize($combined_vector);
5298
5299 // Update the database
5300 global $wpdb;
5301 $table_name = $wpdb->prefix . 'mxchat_intents';
5302
5303 $result = $wpdb->update(
5304 $table_name,
5305 array(
5306 'intent_label' => $intent_label,
5307 'phrases' => implode(', ', $phrases_array),
5308 'embedding_vector' => $serialized_vector,
5309 'similarity_threshold' => $similarity_threshold,
5310 'enabled_bots' => $enabled_bots_json, // Include enabled_bots in update
5311 ),
5312 array('id' => $intent_id),
5313 array('%s', '%s', '%s', '%f', '%s'), // Format: string, string, string, float, string
5314 array('%d') // Where format: integer
5315 );
5316
5317 if (false === $result) {
5318 $this->handle_embedding_error(__('Failed to update action in database.', 'mxchat'));
5319 return;
5320 }
5321
5322 // Set success message and redirect
5323 set_transient('mxchat_admin_notice_success', __('Intent updated successfully!', 'mxchat'), 60);
5324
5325 $redirect_url = add_query_arg(
5326 array(
5327 'page' => 'mxchat-actions'
5328 ),
5329 admin_url('admin.php')
5330 );
5331 wp_safe_redirect($redirect_url);
5332 exit;
5333 }
5334 public function mxchat_handle_add_intent() {
5335 if (!current_user_can('manage_options')) {
5336 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5337 }
5338 check_admin_referer('mxchat_add_intent_nonce');
5339 global $wpdb;
5340 $table_name = $wpdb->prefix . 'mxchat_intents';
5341
5342 // Sanitize and get form data
5343 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5344 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5345 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5346
5347 // Get similarity threshold from form (convert percentage to decimal)
5348 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5349
5350 // Handle enabled_bots
5351 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5352 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5353
5354 // Ensure default is always included for backward compatibility with existing actions
5355 if (!in_array('default', $enabled_bots)) {
5356 $enabled_bots[] = 'default';
5357 }
5358
5359 $enabled_bots_json = json_encode($enabled_bots);
5360
5361 // Validate required fields
5362 if (empty($intent_label) || empty($callback_function) || empty($phrases_input)) {
5363 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5364 return;
5365 }
5366
5367 // Validate callback function
5368 $available_callbacks = $this->mxchat_get_available_callbacks();
5369 if (!array_key_exists($callback_function, $available_callbacks)) {
5370 $this->handle_embedding_error(__('Invalid callback function selected.', 'mxchat'));
5371 return;
5372 }
5373
5374 // Check if this is an add-on promotional placeholder (not a real action)
5375 if (!empty($available_callbacks[$callback_function]['addon_promo'])) {
5376 $addon_name = isset($available_callbacks[$callback_function]['addon_name']) ? $available_callbacks[$callback_function]['addon_name'] : __('an add-on', 'mxchat');
5377 $this->handle_embedding_error(sprintf(
5378 __('This action requires the %s to be installed and activated.', 'mxchat'),
5379 $addon_name
5380 ));
5381 return;
5382 }
5383
5384 // Process phrases
5385 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5386 if (empty($phrases_array)) {
5387 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5388 return;
5389 }
5390
5391 // Generate embeddings with improved error handling
5392 $vectors = [];
5393 $failed_phrases = [];
5394 foreach ($phrases_array as $phrase) {
5395 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5396 if (is_array($embedding_vector)) {
5397 $vectors[] = $embedding_vector;
5398 } else {
5399 $failed_phrases[] = $phrase;
5400 }
5401 }
5402
5403 // Check for embedding failures
5404 if (!empty($failed_phrases)) {
5405 $this->handle_embedding_error(
5406 sprintf(
5407 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5408 implode(', ', $failed_phrases)
5409 )
5410 );
5411 return;
5412 }
5413
5414 if (empty($vectors)) {
5415 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5416 return;
5417 }
5418
5419 // Create combined vector and insert into database
5420 $combined_vector = $this->mxchat_average_vectors($vectors);
5421 $serialized_vector = maybe_serialize($combined_vector);
5422
5423 $result = $wpdb->insert($table_name, [
5424 'intent_label' => $intent_label,
5425 'phrases' => implode(', ', $phrases_array),
5426 'embedding_vector' => $serialized_vector,
5427 'callback_function' => $callback_function,
5428 'similarity_threshold' => $similarity_threshold,
5429 'enabled_bots' => $enabled_bots_json, // NEW field
5430 ]);
5431
5432 if ($result === false) {
5433 $this->handle_embedding_error(__('Database error: ', 'mxchat') . $wpdb->last_error);
5434 return;
5435 }
5436
5437 // Set success message and redirect
5438 set_transient('mxchat_admin_notice_success', __('New intent added successfully!', 'mxchat'), 60);
5439 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5440 exit;
5441 }
5442
5443
5444
5445
5446 private function handle_embedding_error($message, $redirect = true) {
5447 // Store the error message in the existing transient
5448 set_transient('mxchat_admin_notice_error', $message, 60);
5449
5450 if ($redirect) {
5451 // Redirect back to the actions page
5452 $redirect_url = add_query_arg(
5453 array(
5454 'page' => 'mxchat-actions'
5455 ),
5456 admin_url('admin.php')
5457 );
5458 wp_safe_redirect($redirect_url);
5459 exit;
5460 }
5461 }
5462
5463 /**
5464 * AJAX handler to fetch actions list for the new split-panel UI
5465 */
5466 public function mxchat_fetch_actions_list() {
5467 check_ajax_referer('mxchat_actions_nonce', 'security');
5468
5469 if (!current_user_can('manage_options')) {
5470 wp_send_json_error(__('Unauthorized', 'mxchat'));
5471 }
5472
5473 global $wpdb;
5474 $table_name = $wpdb->prefix . 'mxchat_intents';
5475
5476 $page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
5477 $per_page = isset($_POST['per_page']) ? min(100, max(1, intval($_POST['per_page']))) : 50;
5478 $offset = ($page - 1) * $per_page;
5479 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
5480 $callback_filter = isset($_POST['callback_filter']) ? sanitize_text_field($_POST['callback_filter']) : '';
5481 $sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] === 'asc' ? 'ASC' : 'DESC';
5482
5483 // Build WHERE clause
5484 $where = '1=1';
5485 $params = array();
5486
5487 if ($search) {
5488 $search_like = '%' . $wpdb->esc_like($search) . '%';
5489 $where .= ' AND (intent_label LIKE %s OR phrases LIKE %s)';
5490 $params[] = $search_like;
5491 $params[] = $search_like;
5492 }
5493
5494 if ($callback_filter) {
5495 $where .= ' AND callback_function = %s';
5496 $params[] = $callback_filter;
5497 }
5498
5499 // Get total count
5500 $count_query = "SELECT COUNT(*) FROM $table_name WHERE $where";
5501 if (!empty($params)) {
5502 $count_query = $wpdb->prepare($count_query, $params);
5503 }
5504 $total_actions = $wpdb->get_var($count_query);
5505
5506 // Get actions
5507 $query = "SELECT * FROM $table_name WHERE $where ORDER BY id $sort_order LIMIT %d OFFSET %d";
5508 $all_params = array_merge($params, array($per_page, $offset));
5509 $actions = $wpdb->get_results($wpdb->prepare($query, $all_params));
5510
5511 // Get available callbacks for labels/icons
5512 $available_callbacks = $this->mxchat_get_available_callbacks();
5513
5514 // Prefetch individual phrase counts for all fetched actions
5515 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5516 $phrase_counts = array();
5517 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5518 $action_ids = wp_list_pluck($actions, 'id');
5519 if (!empty($action_ids)) {
5520 $id_placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5521 $count_results = $wpdb->get_results($wpdb->prepare(
5522 "SELECT intent_id, COUNT(*) as cnt FROM $phrases_table WHERE intent_id IN ($id_placeholders) GROUP BY intent_id",
5523 $action_ids
5524 ));
5525 foreach ($count_results as $row) {
5526 $phrase_counts[$row->intent_id] = intval($row->cnt);
5527 }
5528 }
5529 }
5530
5531 // Format actions for response
5532 $formatted_actions = array();
5533 foreach ($actions as $action) {
5534 $callback_data = isset($available_callbacks[$action->callback_function])
5535 ? $available_callbacks[$action->callback_function]
5536 : array('label' => $action->callback_function, 'icon' => 'admin-generic');
5537
5538 $enabled_bots = json_decode($action->enabled_bots, true);
5539 if (!is_array($enabled_bots)) {
5540 $enabled_bots = array('default');
5541 }
5542
5543 $formatted_actions[] = array(
5544 'id' => intval($action->id),
5545 'label' => $action->intent_label,
5546 'phrases' => $action->phrases,
5547 'callback_function' => $action->callback_function,
5548 'callback_label' => $callback_data['label'],
5549 'icon' => isset($callback_data['icon']) ? $callback_data['icon'] : 'admin-generic',
5550 'threshold' => round($action->similarity_threshold * 100),
5551 'enabled' => (bool) $action->enabled,
5552 'enabled_bots' => $enabled_bots,
5553 'has_legacy_vector' => !empty($action->embedding_vector),
5554 'individual_phrase_count' => isset($phrase_counts[$action->id]) ? $phrase_counts[$action->id] : 0,
5555 );
5556 }
5557
5558 $total_pages = ceil($total_actions / $per_page);
5559 $showing_start = $total_actions > 0 ? $offset + 1 : 0;
5560 $showing_end = min($offset + $per_page, $total_actions);
5561
5562 wp_send_json_success(array(
5563 'actions' => $formatted_actions,
5564 'page' => $page,
5565 'per_page' => $per_page,
5566 'total_actions' => intval($total_actions),
5567 'total_pages' => $total_pages,
5568 'showing_start' => $showing_start,
5569 'showing_end' => $showing_end,
5570 ));
5571 }
5572
5573 /**
5574 * AJAX handler to toggle action enabled status
5575 */
5576 public function mxchat_toggle_action_status() {
5577 check_ajax_referer('mxchat_actions_nonce', 'security');
5578
5579 if (!current_user_can('manage_options')) {
5580 wp_send_json_error(__('Unauthorized', 'mxchat'));
5581 }
5582
5583 $action_id = isset($_POST['action_id']) ? intval($_POST['action_id']) : 0;
5584 $enabled = isset($_POST['enabled']) ? intval($_POST['enabled']) : 0;
5585
5586 if (!$action_id) {
5587 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5588 }
5589
5590 global $wpdb;
5591 $table_name = $wpdb->prefix . 'mxchat_intents';
5592
5593 $result = $wpdb->update(
5594 $table_name,
5595 array('enabled' => $enabled ? 1 : 0),
5596 array('id' => $action_id),
5597 array('%d'),
5598 array('%d')
5599 );
5600
5601 if ($result === false) {
5602 wp_send_json_error(__('Failed to update action status', 'mxchat'));
5603 }
5604
5605 wp_send_json_success(array('enabled' => (bool) $enabled));
5606 }
5607
5608 /**
5609 * AJAX handler to bulk delete actions
5610 */
5611 public function mxchat_bulk_delete_actions() {
5612 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5613
5614 if (!current_user_can('manage_options')) {
5615 wp_send_json_error(__('Unauthorized', 'mxchat'));
5616 }
5617
5618 $action_ids = isset($_POST['action_ids']) ? array_map('intval', (array) $_POST['action_ids']) : array();
5619
5620 if (empty($action_ids)) {
5621 wp_send_json_error(__('No actions selected', 'mxchat'));
5622 }
5623
5624 global $wpdb;
5625 $table_name = $wpdb->prefix . 'mxchat_intents';
5626
5627 $placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5628 $query = $wpdb->prepare("DELETE FROM $table_name WHERE id IN ($placeholders)", $action_ids);
5629 $result = $wpdb->query($query);
5630
5631 if ($result === false) {
5632 wp_send_json_error(__('Failed to delete actions', 'mxchat'));
5633 }
5634
5635 // Also delete individual phrases for these intents
5636 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5637 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5638 $wpdb->query($wpdb->prepare("DELETE FROM $phrases_table WHERE intent_id IN ($placeholders)", $action_ids));
5639 }
5640
5641 wp_send_json_success(array('deleted' => $result));
5642 }
5643
5644 /**
5645 * AJAX handler to add a new intent/action
5646 */
5647 public function mxchat_add_intent_ajax() {
5648 check_ajax_referer('mxchat_add_intent_nonce', 'security');
5649
5650 if (!current_user_can('manage_options')) {
5651 wp_send_json_error(__('Unauthorized', 'mxchat'));
5652 }
5653
5654 global $wpdb;
5655 $table_name = $wpdb->prefix . 'mxchat_intents';
5656
5657 // Sanitize input
5658 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5659 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5660 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5661 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5662 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5663
5664 // Validate
5665 // Check if using new individual phrases mode or legacy mode
5666 $individual_phrases = isset($_POST['individual_phrases']) ? array_filter(array_map('sanitize_text_field', (array) $_POST['individual_phrases'])) : array();
5667 $use_individual = !empty($individual_phrases);
5668
5669 // Validate required fields (phrases not required when using individual mode)
5670 if (empty($intent_label) || empty($callback_function)) {
5671 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5672 }
5673 if (!$use_individual && empty($phrases_input)) {
5674 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5675 }
5676
5677 // Ensure default bot is included
5678 if (!in_array('default', $enabled_bots)) {
5679 $enabled_bots[] = 'default';
5680 }
5681 $enabled_bots_json = json_encode($enabled_bots);
5682
5683 if ($use_individual) {
5684 // New mode: individual phrases each get their own vector
5685 // Insert the intent row with empty legacy fields
5686 $result = $wpdb->insert(
5687 $table_name,
5688 array(
5689 'intent_label' => $intent_label,
5690 'phrases' => '',
5691 'embedding_vector' => '',
5692 'similarity_threshold' => $similarity_threshold,
5693 'callback_function' => $callback_function,
5694 'enabled' => 1,
5695 'enabled_bots' => $enabled_bots_json,
5696 )
5697 );
5698
5699 if ($result === false) {
5700 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5701 }
5702
5703 $intent_id = $wpdb->insert_id;
5704
5705 // Insert each phrase individually with its own embedding
5706 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5707 $failed_phrases = array();
5708 foreach ($individual_phrases as $phrase) {
5709 $phrase = trim($phrase);
5710 if (empty($phrase)) continue;
5711
5712 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5713 if (is_wp_error($embedding_vector)) {
5714 $failed_phrases[] = $phrase;
5715 continue;
5716 }
5717
5718 $wpdb->insert(
5719 $phrases_table,
5720 array(
5721 'intent_id' => $intent_id,
5722 'phrase' => $phrase,
5723 'embedding_vector' => maybe_serialize($embedding_vector),
5724 )
5725 );
5726 }
5727
5728 $response = array('id' => $intent_id);
5729 if (!empty($failed_phrases)) {
5730 $response['failed_phrases'] = $failed_phrases;
5731 }
5732 wp_send_json_success($response);
5733
5734 } else {
5735 // Legacy mode: combine all phrases into one embedding (backwards compatible)
5736 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
5737 if (empty($phrases_array)) {
5738 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
5739 }
5740
5741 // Generate embedding (combine phrases into single string for embedding)
5742 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
5743 if (is_wp_error($embedding_vector)) {
5744 // Fallback: store without embedding
5745 $serialized_vector = null;
5746 } else {
5747 $serialized_vector = maybe_serialize($embedding_vector);
5748 }
5749
5750 // Insert
5751 $result = $wpdb->insert(
5752 $table_name,
5753 array(
5754 'intent_label' => $intent_label,
5755 'phrases' => implode(', ', $phrases_array),
5756 'embedding_vector' => $serialized_vector,
5757 'similarity_threshold' => $similarity_threshold,
5758 'callback_function' => $callback_function,
5759 'enabled' => 1,
5760 'enabled_bots' => $enabled_bots_json,
5761 )
5762 );
5763
5764 if ($result === false) {
5765 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5766 }
5767
5768 wp_send_json_success(array('id' => $wpdb->insert_id));
5769 }
5770 }
5771
5772 /**
5773 * AJAX handler to edit an existing intent/action
5774 */
5775 public function mxchat_edit_intent_ajax() {
5776 check_ajax_referer('mxchat_edit_intent', 'security');
5777
5778 if (!current_user_can('manage_options')) {
5779 wp_send_json_error(__('Unauthorized', 'mxchat'));
5780 }
5781
5782 global $wpdb;
5783 $table_name = $wpdb->prefix . 'mxchat_intents';
5784
5785 // Sanitize input
5786 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5787 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5788 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5789 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5790 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5791 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5792
5793 // Validate
5794 if (!$intent_id || empty($intent_label)) {
5795 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5796 }
5797
5798 // Check if phrases are managed individually (empty phrases_input means individual mode)
5799 $uses_individual_phrases = empty($phrases_input);
5800
5801 if ($uses_individual_phrases) {
5802 // Individual phrase mode: only update non-phrase fields, skip embedding regeneration
5803 $update_data = array(
5804 'intent_label' => $intent_label,
5805 'similarity_threshold' => $similarity_threshold,
5806 'callback_function' => $callback_function,
5807 'enabled_bots' => json_encode($enabled_bots),
5808 );
5809 } else {
5810 // Legacy mode: process phrases and regenerate embedding (backwards compatible for add-ons)
5811 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
5812 if (empty($phrases_array)) {
5813 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
5814 }
5815
5816 // Generate new embedding (combine phrases into single string for embedding)
5817 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
5818 if (is_wp_error($embedding_vector)) {
5819 // Keep existing embedding
5820 $update_data = array(
5821 'intent_label' => $intent_label,
5822 'phrases' => implode(', ', $phrases_array),
5823 'similarity_threshold' => $similarity_threshold,
5824 'callback_function' => $callback_function,
5825 'enabled_bots' => json_encode($enabled_bots),
5826 );
5827 } else {
5828 $serialized_vector = maybe_serialize($embedding_vector);
5829 $update_data = array(
5830 'intent_label' => $intent_label,
5831 'phrases' => implode(', ', $phrases_array),
5832 'embedding_vector' => $serialized_vector,
5833 'similarity_threshold' => $similarity_threshold,
5834 'callback_function' => $callback_function,
5835 'enabled_bots' => json_encode($enabled_bots),
5836 );
5837 }
5838 }
5839
5840 // Ensure default bot is included
5841 if (!in_array('default', $enabled_bots)) {
5842 $enabled_bots[] = 'default';
5843 }
5844 $update_data['enabled_bots'] = json_encode($enabled_bots);
5845
5846 $result = $wpdb->update(
5847 $table_name,
5848 $update_data,
5849 array('id' => $intent_id),
5850 null,
5851 array('%d')
5852 );
5853
5854 if ($result === false) {
5855 wp_send_json_error(__('Failed to update action.', 'mxchat'));
5856 }
5857
5858 wp_send_json_success(array('updated' => true));
5859 }
5860
5861 /**
5862 * AJAX handler to delete a single intent/action
5863 */
5864 public function mxchat_delete_intent_ajax() {
5865 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5866
5867 if (!current_user_can('manage_options')) {
5868 wp_send_json_error(__('Unauthorized', 'mxchat'));
5869 }
5870
5871 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5872
5873 if (!$intent_id) {
5874 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5875 }
5876
5877 global $wpdb;
5878 $table_name = $wpdb->prefix . 'mxchat_intents';
5879
5880 $result = $wpdb->delete($table_name, array('id' => $intent_id), array('%d'));
5881
5882 if ($result === false) {
5883 wp_send_json_error(__('Failed to delete action', 'mxchat'));
5884 }
5885
5886 // Also delete individual phrases for this intent
5887 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5888 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5889 $wpdb->delete($phrases_table, array('intent_id' => $intent_id), array('%d'));
5890 }
5891
5892 wp_send_json_success(array('deleted' => true));
5893 }
5894
5895 /**
5896 * AJAX handler to add a single phrase with its own embedding to an intent
5897 */
5898 public function mxchat_add_phrase_ajax() {
5899 check_ajax_referer('mxchat_add_phrase_nonce', 'security');
5900
5901 if (!current_user_can('manage_options')) {
5902 wp_send_json_error(__('Unauthorized', 'mxchat'));
5903 }
5904
5905 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5906 $phrase = isset($_POST['phrase']) ? sanitize_text_field($_POST['phrase']) : '';
5907
5908 if (!$intent_id || empty($phrase)) {
5909 wp_send_json_error(__('Please provide an intent ID and phrase.', 'mxchat'));
5910 }
5911
5912 // Verify the intent exists
5913 global $wpdb;
5914 $intents_table = $wpdb->prefix . 'mxchat_intents';
5915 $intent = $wpdb->get_row($wpdb->prepare("SELECT id FROM $intents_table WHERE id = %d", $intent_id));
5916 if (!$intent) {
5917 wp_send_json_error(__('Action not found.', 'mxchat'));
5918 }
5919
5920 // Generate embedding for this single phrase
5921 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5922 if (is_wp_error($embedding_vector)) {
5923 wp_send_json_error(__('Failed to generate embedding: ', 'mxchat') . $embedding_vector->get_error_message());
5924 }
5925
5926 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5927 $result = $wpdb->insert(
5928 $phrases_table,
5929 array(
5930 'intent_id' => $intent_id,
5931 'phrase' => $phrase,
5932 'embedding_vector' => maybe_serialize($embedding_vector),
5933 )
5934 );
5935
5936 if ($result === false) {
5937 wp_send_json_error(__('Failed to add phrase.', 'mxchat'));
5938 }
5939
5940 wp_send_json_success(array('id' => $wpdb->insert_id, 'phrase' => $phrase));
5941 }
5942
5943 /**
5944 * AJAX handler to delete a single phrase from wp_mxchat_intent_phrases
5945 */
5946 public function mxchat_delete_phrase_ajax() {
5947 check_ajax_referer('mxchat_delete_phrase_nonce', 'security');
5948
5949 if (!current_user_can('manage_options')) {
5950 wp_send_json_error(__('Unauthorized', 'mxchat'));
5951 }
5952
5953 $phrase_id = isset($_POST['phrase_id']) ? intval($_POST['phrase_id']) : 0;
5954 if (!$phrase_id) {
5955 wp_send_json_error(__('Invalid phrase ID.', 'mxchat'));
5956 }
5957
5958 global $wpdb;
5959 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5960 $result = $wpdb->delete($phrases_table, array('id' => $phrase_id), array('%d'));
5961
5962 if ($result === false) {
5963 wp_send_json_error(__('Failed to delete phrase.', 'mxchat'));
5964 }
5965
5966 wp_send_json_success(array('deleted' => true));
5967 }
5968
5969 /**
5970 * AJAX handler to fetch individual phrases for an intent
5971 */
5972 public function mxchat_get_phrases_ajax() {
5973 check_ajax_referer('mxchat_get_phrases_nonce', 'security');
5974
5975 if (!current_user_can('manage_options')) {
5976 wp_send_json_error(__('Unauthorized', 'mxchat'));
5977 }
5978
5979 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
5980 if (!$intent_id) {
5981 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
5982 }
5983
5984 global $wpdb;
5985 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5986
5987 $phrases = array();
5988 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5989 $phrases = $wpdb->get_results($wpdb->prepare(
5990 "SELECT id, phrase, created_at FROM $phrases_table WHERE intent_id = %d ORDER BY created_at ASC",
5991 $intent_id
5992 ));
5993 }
5994
5995 wp_send_json_success(array('phrases' => $phrases));
5996 }
5997
5998 /**
5999 * AJAX handler to clear legacy phrases and embedding from the main intents table
6000 */
6001 public function mxchat_delete_legacy_phrases_ajax() {
6002 check_ajax_referer('mxchat_delete_legacy_nonce', 'security');
6003
6004 if (!current_user_can('manage_options')) {
6005 wp_send_json_error(__('Unauthorized', 'mxchat'));
6006 }
6007
6008 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6009 if (!$intent_id) {
6010 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
6011 }
6012
6013 global $wpdb;
6014 $table_name = $wpdb->prefix . 'mxchat_intents';
6015
6016 $result = $wpdb->update(
6017 $table_name,
6018 array('phrases' => '', 'embedding_vector' => ''),
6019 array('id' => $intent_id),
6020 array('%s', '%s'),
6021 array('%d')
6022 );
6023
6024 if ($result === false) {
6025 wp_send_json_error(__('Failed to clear legacy phrases.', 'mxchat'));
6026 }
6027
6028 wp_send_json_success(array('cleared' => true));
6029 }
6030
6031 /**
6032 * Enhanced get_available_callbacks function with form action exclusion
6033 *
6034 * @param bool $grouped Whether to return callbacks grouped by category
6035 * @param bool $include_all Whether to include all potential actions (even if add-on not installed)
6036 * @return array Callbacks data with icons, descriptions and availability status
6037 */
6038 private function mxchat_get_available_callbacks($grouped = false, $include_all = true) {
6039 // Load WordPress plugin functions if needed
6040 if (!function_exists('get_plugins')) {
6041 require_once ABSPATH . 'wp-admin/includes/plugin.php';
6042 }
6043
6044 // Get active plugins
6045 $active_plugins = get_option('active_plugins', array());
6046
6047 // Functions to exclude from the action selector only if Pro is activated
6048 // If user doesn't have Pro, show these so they can see what they're missing
6049 $excluded_when_pro_active_functions = array(
6050 'mxchat_handle_form_collection' // Forms add-on action
6051 );
6052
6053 // Always excluded functions (regardless of Pro status)
6054 $always_excluded_functions = array();
6055
6056 // Combine exclusion lists based on Pro activation status
6057 $excluded_functions = $always_excluded_functions;
6058 if ($this->is_activated) {
6059 // Only exclude add-on managed functions if Pro is active
6060 $excluded_functions = array_merge($excluded_functions, $excluded_when_pro_active_functions);
6061 }
6062
6063 // Define add-on plugin files and their corresponding action functions
6064 $addon_plugins = array(
6065 'mxchat-woo/mxchat-woo.php' => array(
6066 'functions' => array(
6067 'mxchat_handle_product_recommendations',
6068 'mxchat_handle_order_history',
6069 'mxchat_show_product_card',
6070 'mxchat_add_to_cart',
6071 'mxchat_checkout_redirect',
6072 'mxchat_handle_featured_products'
6073 ),
6074 'name' => __('WooCommerce Add-on', 'mxchat'),
6075 'pro_required' => true
6076 ),
6077 'mxchat-perplexity/mxchat-perplexity.php' => array(
6078 'functions' => array('mxchat_perplexity_research'),
6079 'name' => __('Perplexity Add-on', 'mxchat'),
6080 'pro_required' => true
6081 ),
6082 'mxchat-forms/mxchat-forms.php' => array(
6083 'functions' => array('mxchat_handle_form_collection'),
6084 'name' => __('Forms Add-on', 'mxchat'),
6085 'pro_required' => true
6086 ),
6087 // Add other add-ons and their functions here
6088 );
6089
6090 // Get the functions that are provided by active add-ons
6091 $addon_provided_functions = array();
6092 $addon_function_mapping = array(); // Maps functions to their add-on info
6093
6094 // Check which add-ons are active
6095 foreach ($addon_plugins as $plugin_file => $addon_info) {
6096 $is_active = in_array($plugin_file, $active_plugins);
6097
6098 // For each function in this addon
6099 foreach ($addon_info['functions'] as $function) {
6100 // Consider a function installed only if:
6101 // 1. The add-on is active AND
6102 // 2. Either it doesn't require Pro OR Pro is activated
6103 $is_installed = $is_active && (!$addon_info['pro_required'] || $this->is_activated);
6104
6105 // If the add-on is installed, mark this function as provided by an add-on
6106 if ($is_installed) {
6107 $addon_provided_functions[] = $function;
6108 }
6109
6110 // Store addon info for this function regardless of installation status
6111 $addon_function_mapping[$function] = array(
6112 'addon' => basename(dirname($plugin_file)),
6113 'addon_name' => $addon_info['name'],
6114 'pro_required' => $addon_info['pro_required'],
6115 'is_active' => $is_active,
6116 'is_installed' => $is_installed
6117 );
6118 }
6119 }
6120
6121 // Core callbacks - always available in the base plugin
6122 $core_callbacks = array(
6123 'mxchat_handle_email_capture' => array(
6124 'label' => __('Loops Email Capture', 'mxchat'),
6125 'pro_only' => false,
6126 'group' => __('Customer Engagement', 'mxchat'),
6127 'icon' => 'email-alt',
6128 'description' => __('Collect visitor emails for your mailing list in Loops', 'mxchat'),
6129 'addon' => false, // Not from an add-on
6130 'installed' => true // Always installed with base plugin
6131 ),
6132 'mxchat_handle_search_request' => array(
6133 'label' => __('Brave Web Search', 'mxchat'),
6134 'pro_only' => false,
6135 'group' => __('Search Features', 'mxchat'),
6136 'icon' => 'search',
6137 'description' => __('Let users search the web directly from the chat (requires a Brave Search API key)', 'mxchat'),
6138 'addon' => false,
6139 'installed' => true
6140 ),
6141 'mxchat_handle_image_search_request' => array(
6142 'label' => __('Brave Image Search', 'mxchat'),
6143 'pro_only' => false,
6144 'group' => __('Search Features', 'mxchat'),
6145 'icon' => 'format-image',
6146 'description' => __('Search and display images in the chat conversation (requires a Brave Search API key)', 'mxchat'),
6147 'addon' => false,
6148 'installed' => true
6149 ),
6150 // Pro core features - check is_activated property
6151 'mxchat_generate_image' => array(
6152 'label' => __('Generate Image (OpenAI)', 'mxchat'),
6153 'pro_only' => false,
6154 'group' => __('Other Features', 'mxchat'),
6155 'icon' => 'art',
6156 'description' => __('Create images with GPT Image from OpenAI (requires OpenAI API key)', 'mxchat'),
6157 'addon' => false,
6158 'installed' => true
6159 ),
6160 'mxchat_generate_gemini_image' => array(
6161 'label' => __('Generate Image (Gemini)', 'mxchat'),
6162 'pro_only' => false,
6163 'group' => __('Other Features', 'mxchat'),
6164 'icon' => 'art',
6165 'description' => __('Create images with Imagen from Google (requires Gemini API key)', 'mxchat'),
6166 'addon' => false,
6167 'installed' => true
6168 ),
6169 'mxchat_handle_pdf_discussion' => array(
6170 'label' => __('Chat with PDF', 'mxchat'),
6171 'pro_only' => false,
6172 'group' => __('Other Features', 'mxchat'),
6173 'icon' => 'media-document',
6174 'description' => __('Answer questions about uploaded PDF documents', 'mxchat'),
6175 'addon' => false,
6176 'installed' => true
6177 ),
6178 'mxchat_live_agent_handover' => array(
6179 'label' => __('Slack Live Agent', 'mxchat'),
6180 'pro_only' => false,
6181 'group' => __('Customer Engagement', 'mxchat'),
6182 'icon' => 'admin-users',
6183 'description' => __('Transfer conversation to a human support agent on Slack', 'mxchat'),
6184 'addon' => false,
6185 'installed' => true
6186 ),
6187 'mxchat_telegram_live_agent_handover' => array(
6188 'label' => __('Telegram Live Agent', 'mxchat'),
6189 'pro_only' => false,
6190 'group' => __('Customer Engagement', 'mxchat'),
6191 'icon' => 'format-chat',
6192 'description' => __('Transfer conversation to a human support agent on Telegram', 'mxchat'),
6193 'addon' => false,
6194 'installed' => true
6195 ),
6196 'mxchat_webhook_live_agent_handover' => array(
6197 'label' => __('Webhook Live Agent', 'mxchat'),
6198 'pro_only' => false,
6199 'group' => __('Customer Engagement', 'mxchat'),
6200 'icon' => 'external',
6201 'description' => __('Send the conversation to your own webhook URL so your team can follow up', 'mxchat'),
6202 'addon' => false,
6203 'installed' => true
6204 ),
6205 'mxchat_handle_switch_to_chatbot_intent' => array(
6206 'label' => __('Back to Chatbot', 'mxchat'),
6207 'pro_only' => false,
6208 'group' => __('Customer Engagement', 'mxchat'),
6209 'icon' => 'backup',
6210 'description' => __('Return from live agent mode to AI chatbot', 'mxchat'),
6211 'addon' => false,
6212 'installed' => true
6213 ),
6214 );
6215
6216 // Add-on callbacks with placeholders - only include if the add-on is NOT active
6217 // These are promotional/informational only — not selectable as real actions
6218 $addon_callbacks = array(
6219 // WooCommerce Add-on
6220 'mxchat_handle_product_recommendations' => array(
6221 'label' => __('Product Recommendations', 'mxchat'),
6222 'pro_only' => false,
6223 'addon_promo' => true,
6224 'group' => __('WooCommerce Features', 'mxchat'),
6225 'icon' => 'cart',
6226 'description' => __('Suggest products based on customer preferences', 'mxchat'),
6227 ),
6228 'mxchat_handle_order_history' => array(
6229 'label' => __('Order History', 'mxchat'),
6230 'pro_only' => false,
6231 'addon_promo' => true,
6232 'group' => __('WooCommerce Features', 'mxchat'),
6233 'icon' => 'clipboard',
6234 'description' => __('Allow customers to check their order status', 'mxchat'),
6235 ),
6236 'mxchat_show_product_card' => array(
6237 'label' => __('Show Product Card', 'mxchat'),
6238 'pro_only' => false,
6239 'addon_promo' => true,
6240 'group' => __('WooCommerce Features', 'mxchat'),
6241 'icon' => 'products',
6242 'description' => __('Display product information in the chat', 'mxchat'),
6243 ),
6244 'mxchat_add_to_cart' => array(
6245 'label' => __('Add to Cart', 'mxchat'),
6246 'pro_only' => false,
6247 'addon_promo' => true,
6248 'group' => __('WooCommerce Features', 'mxchat'),
6249 'icon' => 'plus-alt',
6250 'description' => __('Add products to cart directly from chat', 'mxchat'),
6251 ),
6252 'mxchat_checkout_redirect' => array(
6253 'label' => __('Proceed to Checkout', 'mxchat'),
6254 'pro_only' => false,
6255 'addon_promo' => true,
6256 'group' => __('WooCommerce Features', 'mxchat'),
6257 'icon' => 'arrow-right-alt',
6258 'description' => __('Redirect customer to checkout page', 'mxchat'),
6259 ),
6260 'mxchat_handle_featured_products' => array(
6261 'label' => __('Featured Products Showcase', 'mxchat'),
6262 'pro_only' => false,
6263 'addon_promo' => true,
6264 'group' => __('WooCommerce Features', 'mxchat'),
6265 'icon' => 'star-filled',
6266 'description' => __('Display a curated selection of products with an AI-generated message', 'mxchat'),
6267 ),
6268
6269 // Perplexity Add-on
6270 'mxchat_perplexity_research' => array(
6271 'label' => __('Perplexity Research', 'mxchat'),
6272 'pro_only' => false,
6273 'addon_promo' => true,
6274 'group' => __('Search Features', 'mxchat'),
6275 'icon' => 'book-alt',
6276 'description' => __('Allows the chatbot to search the web for accurate, up-to-date answers', 'mxchat'),
6277 ),
6278
6279 // Forms Add-on
6280 'mxchat_handle_form_collection' => array(
6281 'label' => __('Form Collection', 'mxchat'),
6282 'pro_only' => false,
6283 'addon_promo' => true,
6284 'group' => __('Form Features', 'mxchat'),
6285 'icon' => 'feedback',
6286 'description' => __('Collect user information through custom forms in chat', 'mxchat'),
6287 ),
6288 );
6289
6290 // Enhance add-on callbacks with installation status and addon info
6291 foreach ($addon_callbacks as $function => $data) {
6292 if (isset($addon_function_mapping[$function])) {
6293 $addon_info = $addon_function_mapping[$function];
6294
6295 $addon_callbacks[$function]['addon'] = $addon_info['addon'];
6296 $addon_callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6297 $addon_callbacks[$function]['installed'] = $addon_info['is_installed'];
6298
6299 // Set pro_only based on add-on configuration
6300 $addon_callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6301 } else {
6302 $addon_callbacks[$function]['addon'] = 'unknown';
6303 $addon_callbacks[$function]['addon_name'] = __('Unknown Add-on', 'mxchat');
6304 $addon_callbacks[$function]['installed'] = false;
6305 }
6306 }
6307
6308 // Initialize callbacks with core features
6309 $callbacks = $core_callbacks;
6310
6311 // Get callbacks from active add-ons
6312 $active_addon_callbacks = apply_filters('mxchat_available_callbacks', array());
6313
6314 // Add placeholder callbacks only for add-ons that aren't active
6315 if ($include_all) {
6316 foreach ($addon_callbacks as $function => $data) {
6317 // Skip placeholders for functions provided by active add-ons
6318 if (in_array($function, $addon_provided_functions)) {
6319 continue;
6320 }
6321
6322 // Skip excluded functions
6323 if (in_array($function, $excluded_functions)) {
6324 continue;
6325 }
6326
6327 // Add the placeholder
6328 $callbacks[$function] = $data;
6329 }
6330 }
6331
6332 // Add callbacks from active add-ons (will override placeholders)
6333 foreach ($active_addon_callbacks as $function => $data) {
6334 // Skip excluded functions
6335 if (in_array($function, $excluded_functions)) {
6336 continue;
6337 }
6338
6339 // Always include callbacks from add-ons
6340 $callbacks[$function] = $data;
6341
6342 // Ensure they have the proper add-on info
6343 if (isset($addon_function_mapping[$function])) {
6344 $addon_info = $addon_function_mapping[$function];
6345 $callbacks[$function]['addon'] = $addon_info['addon'];
6346 $callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6347 $callbacks[$function]['installed'] = $addon_info['is_installed'];
6348 $callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6349 }
6350 }
6351
6352 // Just before returning callbacks, sort them to prioritize free features
6353 if (!$grouped) {
6354 // Create temporary arrays for sorting
6355 $free_callbacks = array();
6356 $pro_callbacks = array();
6357
6358 // Split callbacks into free and pro
6359 foreach ($callbacks as $key => $data) {
6360 if (isset($data['pro_only']) && $data['pro_only']) {
6361 $pro_callbacks[$key] = $data;
6362 } else {
6363 $free_callbacks[$key] = $data;
6364 }
6365 }
6366
6367 // Merge with free callbacks first
6368 $callbacks = array_merge($free_callbacks, $pro_callbacks);
6369 }
6370
6371 // Return grouped structure if requested
6372 if ($grouped) {
6373 $grouped_callbacks = array();
6374 foreach ($callbacks as $key => $data) {
6375 $group_label = isset($data['group']) ? $data['group'] : __('Other Features', 'mxchat');
6376
6377 // Ensure we carry forward all the new fields in grouped mode
6378 $callback_data = array(
6379 'label' => $data['label'],
6380 'pro_only' => isset($data['pro_only']) ? $data['pro_only'] : false,
6381 'icon' => isset($data['icon']) ? $data['icon'] : 'admin-generic',
6382 'description' => isset($data['description']) ? $data['description'] : __('Custom action for your chatbot', 'mxchat'),
6383 'addon' => isset($data['addon']) ? $data['addon'] : false,
6384 'addon_name' => isset($data['addon_name']) ? $data['addon_name'] : '',
6385 'installed' => isset($data['installed']) ? $data['installed'] : true
6386 );
6387
6388 $grouped_callbacks[$group_label][$key] = $callback_data;
6389 }
6390
6391 // Sort within each group to prioritize free features
6392 foreach ($grouped_callbacks as $group => $items) {
6393 $free_items = array();
6394 $pro_items = array();
6395
6396 foreach ($items as $key => $data) {
6397 if (isset($data['pro_only']) && $data['pro_only']) {
6398 $pro_items[$key] = $data;
6399 } else {
6400 $free_items[$key] = $data;
6401 }
6402 }
6403
6404 $grouped_callbacks[$group] = array_merge($free_items, $pro_items);
6405 }
6406
6407 return $grouped_callbacks;
6408 }
6409
6410 return $callbacks;
6411 }
6412
6413 public function mxchat_page_init() {
6414 register_setting(
6415 'mxchat_option_group',
6416 'mxchat_options',
6417 array($this, 'mxchat_sanitize')
6418 );
6419
6420 register_setting(
6421 'mxchat_option_group',
6422 'mxchat_similarity_threshold',
6423 array(
6424 'type' => 'number',
6425 'sanitize_callback' => function($value) {
6426 $value = absint($value);
6427 return min(max($value, 20), 95);
6428 },
6429 'default' => 80,
6430 )
6431 );
6432
6433 // Chatbot Settings Section
6434 add_settings_section(
6435 'mxchat_chatbot_section',
6436 esc_html__('Chatbot Settings', 'mxchat'),
6437 null,
6438 'mxchat-chatbot'
6439 );
6440
6441 // API Keys Settings Section
6442 add_settings_section(
6443 'mxchat_api_keys_section',
6444 esc_html__('API Keys', 'mxchat'),
6445 array($this, 'mxchat_api_keys_section_callback'),
6446 'mxchat-api-keys'
6447 );
6448
6449 // OpenAI API Key
6450 add_settings_field(
6451 'api_key',
6452 esc_html__('OpenAI API Key', 'mxchat'),
6453 array($this, 'api_key_callback'),
6454 'mxchat-api-keys',
6455 'mxchat_api_keys_section'
6456 );
6457
6458 // X.AI API Key
6459 add_settings_field(
6460 'xai_api_key',
6461 esc_html__('X.AI API Key', 'mxchat'),
6462 array($this, 'xai_api_key_callback'),
6463 'mxchat-api-keys',
6464 'mxchat_api_keys_section'
6465 );
6466
6467 // Claude API Key
6468 add_settings_field(
6469 'claude_api_key',
6470 esc_html__('Claude API Key', 'mxchat'),
6471 array($this, 'claude_api_key_callback'),
6472 'mxchat-api-keys',
6473 'mxchat_api_keys_section'
6474 );
6475
6476 // DeepSeek API Key
6477 add_settings_field(
6478 'deepseek_api_key',
6479 esc_html__('DeepSeek API Key', 'mxchat'),
6480 array($this, 'deepseek_api_key_callback'),
6481 'mxchat-api-keys',
6482 'mxchat_api_keys_section'
6483 );
6484
6485 // Google Gemini API Key
6486 add_settings_field(
6487 'gemini_api_key',
6488 esc_html__('Google Gemini API Key', 'mxchat'),
6489 array($this, 'gemini_api_key_callback'),
6490 'mxchat-api-keys',
6491 'mxchat_api_keys_section'
6492 );
6493
6494 // Voyage AI API Key
6495 add_settings_field(
6496 'voyage_api_key',
6497 esc_html__('Voyage AI API Key', 'mxchat'),
6498 array($this, 'voyage_api_key_callback'),
6499 'mxchat-api-keys',
6500 'mxchat_api_keys_section'
6501 );
6502
6503 // OpenRouter API Key
6504 add_settings_field(
6505 'openrouter_api_key',
6506 esc_html__('OpenRouter API Key', 'mxchat'),
6507 array($this, 'openrouter_api_key_callback'),
6508 'mxchat-api-keys',
6509 'mxchat_api_keys_section'
6510 );
6511
6512 // Custom (OpenAI-compatible) Provider — for Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
6513 add_settings_field(
6514 'custom_provider',
6515 esc_html__('Custom Provider (OpenAI-compatible)', 'mxchat'),
6516 array($this, 'custom_provider_callback'),
6517 'mxchat-api-keys',
6518 'mxchat_api_keys_section'
6519 );
6520
6521 // Loops API Key
6522 add_settings_field(
6523 'loops_api_key',
6524 esc_html__('Loops API Key', 'mxchat'),
6525 array($this, 'mxchat_loops_api_key_callback'),
6526 'mxchat-api-keys',
6527 'mxchat_api_keys_section'
6528 );
6529
6530 // Brave Search API Key
6531 add_settings_field(
6532 'brave_api_key',
6533 __('Brave API Key', 'mxchat'),
6534 array($this, 'mxchat_brave_api_key_callback'),
6535 'mxchat-api-keys',
6536 'mxchat_api_keys_section'
6537 );
6538
6539 // Similarity Threshold Slider
6540 add_settings_field(
6541 'similarity_threshold', // Field ID
6542 esc_html__('Similarity Threshold', 'mxchat'), // Field title
6543 array($this, 'mxchat_similarity_threshold_callback'), // Callback function
6544 'mxchat-chatbot', // Page
6545 'mxchat_chatbot_section' // Section
6546 );
6547
6548 // RAG Sources Limit Slider
6549 add_settings_field(
6550 'rag_sources_limit', // Field ID
6551 esc_html__('RAG Sources Limit', 'mxchat'), // Field title
6552 array($this, 'mxchat_rag_sources_limit_callback'), // Callback function
6553 'mxchat-chatbot', // Page
6554 'mxchat_chatbot_section' // Section
6555 );
6556
6557 // RAG Chunks Limit Slider
6558 add_settings_field(
6559 'rag_chunks_limit', // Field ID
6560 esc_html__('RAG Chunks Limit', 'mxchat'), // Field title
6561 array($this, 'mxchat_rag_chunks_limit_callback'), // Callback function
6562 'mxchat-chatbot', // Page
6563 'mxchat_chatbot_section' // Section
6564 );
6565
6566 add_settings_field(
6567 'append_to_body',
6568 esc_html__('Auto-Display Chatbot', 'mxchat'),
6569 array($this, 'mxchat_append_to_body_callback'),
6570 'mxchat-chatbot',
6571 'mxchat_chatbot_section'
6572 );
6573
6574 add_settings_field(
6575 'contextual_awareness_toggle',
6576 esc_html__('Contextual Awareness', 'mxchat'),
6577 array($this, 'mxchat_contextual_awareness_callback'),
6578 'mxchat-chatbot',
6579 'mxchat_chatbot_section'
6580 );
6581
6582 add_settings_field(
6583 'citation_links_toggle',
6584 esc_html__('Citation Links', 'mxchat'),
6585 array($this, 'mxchat_citation_links_toggle_callback'),
6586 'mxchat-chatbot',
6587 'mxchat_chatbot_section'
6588 );
6589
6590 // Strip unapproved links (plan 58f8b4) — the enforcement half of the old
6591 // Citation Links conflation, now its own control.
6592 add_settings_field(
6593 'strip_unapproved_links_toggle',
6594 esc_html__('Strip Unapproved Links', 'mxchat'),
6595 array($this, 'mxchat_strip_unapproved_links_toggle_callback'),
6596 'mxchat-chatbot',
6597 'mxchat_chatbot_section'
6598 );
6599
6600 // Satisfaction rating toggle (plan-a5b006).
6601 add_settings_field(
6602 'satisfaction_rating_enabled',
6603 esc_html__('Satisfaction Rating Prompt', 'mxchat'),
6604 array($this, 'mxchat_satisfaction_rating_toggle_callback'),
6605 'mxchat-chatbot',
6606 'mxchat_chatbot_section'
6607 );
6608
6609 // Satisfaction rating customization (plan-141a12, plan-29caac):
6610 // the 5 customization fields are now inline-rendered inside
6611 // mxchat_satisfaction_rating_toggle_callback's sub-options wrapper.
6612
6613 add_settings_field(
6614 'enable_streaming_toggle',
6615 esc_html__('Enable Streaming', 'mxchat'),
6616 array($this, 'enable_streaming_toggle_callback'),
6617 'mxchat-chatbot',
6618 'mxchat_chatbot_section', // Same section as your working toggle
6619 array(
6620 'class' => 'mxchat-setting-row streaming-setting',
6621 'style' => 'display: none;' // Hidden by default, shown when OpenAI/Claude selected
6622 )
6623 );
6624
6625
6626 add_settings_field(
6627 'model',
6628 esc_html__('Chat Model', 'mxchat'),
6629 array($this, 'mxchat_model_callback'),
6630 'mxchat-chatbot',
6631 'mxchat_chatbot_section'
6632 );
6633
6634 add_settings_field(
6635 'embedding_model',
6636 esc_html__('Embedding Model', 'mxchat'),
6637 array($this, 'embedding_model_callback'),
6638 'mxchat-chatbot',
6639 'mxchat_chatbot_section'
6640 );
6641
6642 add_settings_field(
6643 'system_prompt_instructions',
6644 esc_html__('AI Instructions (Behavior)', 'mxchat'),
6645 array($this, 'system_prompt_instructions_callback'),
6646 'mxchat-chatbot',
6647 'mxchat_chatbot_section'
6648 );
6649
6650
6651 add_settings_field(
6652 'top_bar_title',
6653 esc_html__('Top Bar Title', 'mxchat'),
6654 array($this, 'mxchat_top_bar_title_callback'),
6655 'mxchat-chatbot',
6656 'mxchat_chatbot_section'
6657 );
6658
6659 add_settings_field(
6660 'ai_agent_text',
6661 esc_html__('AI Agent Text', 'mxchat'),
6662 array($this, 'mxchat_ai_agent_text_callback'),
6663 'mxchat-chatbot',
6664 'mxchat_chatbot_section'
6665 );
6666
6667 add_settings_field(
6668 'enable_email_block',
6669 esc_html__('Require Email To Chat', 'mxchat'),
6670 array($this, 'enable_email_block_callback'),
6671 'mxchat-chatbot',
6672 'mxchat_chatbot_section'
6673 );
6674
6675 add_settings_field(
6676 'email_blocker_header_content',
6677 esc_html__('Require Email Chat Content', 'mxchat'),
6678 array($this, 'email_blocker_header_content_callback'),
6679 'mxchat-chatbot',
6680 'mxchat_chatbot_section'
6681 );
6682
6683 add_settings_field(
6684 'email_blocker_button_text',
6685 esc_html__('Require Email Chat Button Text', 'mxchat'),
6686 [$this, 'email_blocker_button_text_callback'],
6687 'mxchat-chatbot',
6688 'mxchat_chatbot_section'
6689 );
6690
6691 add_settings_field(
6692 'enable_name_field',
6693 esc_html__('Require Name Field', 'mxchat'),
6694 array($this, 'enable_name_field_callback'),
6695 'mxchat-chatbot',
6696 'mxchat_chatbot_section'
6697 );
6698
6699 add_settings_field(
6700 'name_field_placeholder',
6701 esc_html__('Name Field Placeholder', 'mxchat'),
6702 array($this, 'name_field_placeholder_callback'),
6703 'mxchat-chatbot',
6704 'mxchat_chatbot_section'
6705 );
6706
6707 add_settings_field(
6708 'intro_message',
6709 esc_html__('Introductory Message', 'mxchat'),
6710 array($this, 'mxchat_intro_message_callback'),
6711 'mxchat-chatbot',
6712 'mxchat_chatbot_section'
6713 );
6714
6715 add_settings_field(
6716 'input_copy',
6717 esc_html__('Input Copy', 'mxchat'),
6718 array($this, 'mxchat_input_copy_callback'),
6719 'mxchat-chatbot',
6720 'mxchat_chatbot_section'
6721 );
6722
6723 add_settings_field(
6724 'pre_chat_message',
6725 esc_html__('Chat Teaser Pop-up', 'mxchat'),
6726 array($this, 'mxchat_pre_chat_message_callback'),
6727 'mxchat-chatbot',
6728 'mxchat_chatbot_section'
6729 );
6730
6731 add_settings_field(
6732 'privacy_toggle',
6733 esc_html__('Toggle Privacy Notice', 'mxchat'),
6734 array($this, 'mxchat_privacy_toggle_callback'),
6735 'mxchat-chatbot',
6736 'mxchat_chatbot_section'
6737 );
6738
6739 add_settings_field(
6740 'complianz_toggle',
6741 esc_html__('Enable Complianz', 'mxchat'),
6742 array($this, 'mxchat_complianz_toggle_callback'),
6743 'mxchat-chatbot',
6744 'mxchat_chatbot_section'
6745 );
6746
6747 add_settings_field(
6748 'link_target_toggle',
6749 esc_html__('Open Links in a New Tab', 'mxchat'),
6750 array($this, 'mxchat_link_target_toggle_callback'),
6751 'mxchat-chatbot',
6752 'mxchat_chatbot_section'
6753 );
6754
6755 add_settings_field(
6756 'chat_persistence_toggle',
6757 esc_html__('Enable Chat Persistence', 'mxchat'),
6758 array($this, 'mxchat_chat_persistence_toggle_callback'),
6759 'mxchat-chatbot',
6760 'mxchat_chatbot_section'
6761 );
6762
6763 add_settings_field(
6764 'print_button_enabled',
6765 esc_html__('Show Download Transcript Button', 'mxchat'),
6766 array($this, 'mxchat_print_button_toggle_callback'),
6767 'mxchat-chatbot',
6768 'mxchat_chatbot_section'
6769 );
6770
6771 add_settings_field(
6772 'reset_chat_enabled',
6773 esc_html__('Show Start-New-Chat Button', 'mxchat'),
6774 array($this, 'mxchat_reset_chat_toggle_callback'),
6775 'mxchat-chatbot',
6776 'mxchat_chatbot_section'
6777 );
6778
6779 add_settings_field(
6780 'reset_chat_label',
6781 esc_html__('Start-New-Chat Button Label', 'mxchat'),
6782 array($this, 'mxchat_reset_chat_label_callback'),
6783 'mxchat-chatbot',
6784 'mxchat_chatbot_section'
6785 );
6786
6787 add_settings_field(
6788 'popular_question_1',
6789 esc_html__('Quick Question 1', 'mxchat'),
6790 array($this, 'mxchat_popular_question_1_callback'),
6791 'mxchat-chatbot',
6792 'mxchat_chatbot_section'
6793 );
6794
6795 add_settings_field(
6796 'popular_question_2',
6797 esc_html__('Quick Question 2', 'mxchat'),
6798 array($this, 'mxchat_popular_question_2_callback'),
6799 'mxchat-chatbot',
6800 'mxchat_chatbot_section'
6801 );
6802
6803 add_settings_field(
6804 'popular_question_3',
6805 esc_html__('Quick Question 3', 'mxchat'),
6806 array($this, 'mxchat_popular_question_3_callback'),
6807 'mxchat-chatbot',
6808 'mxchat_chatbot_section'
6809 );
6810
6811 add_settings_field(
6812 'additional_popular_questions',
6813 esc_html__('Additional Quick Questions', 'mxchat'),
6814 array($this, 'mxchat_additional_popular_questions_callback'),
6815 'mxchat-chatbot',
6816 'mxchat_chatbot_section'
6817 );
6818
6819
6820 add_settings_field(
6821 'rate_limits',
6822 __('Rate Limits Settings', 'mxchat'),
6823 array($this, 'mxchat_rate_limits_callback'),
6824 'mxchat-chatbot',
6825 'mxchat_chatbot_section'
6826 );
6827
6828 // Loops Settings Section
6829 add_settings_section(
6830 'mxchat_loops_section',
6831 esc_html__('Loops Settings', 'mxchat'),
6832 null,
6833 'mxchat-embed'
6834 );
6835
6836 // Loops Settings Fields (API Key moved to API Keys tab)
6837 add_settings_field(
6838 'loops_mailing_list',
6839 esc_html__('Loops Mailing List', 'mxchat'),
6840 array($this, 'mxchat_loops_mailing_list_callback'),
6841 'mxchat-embed',
6842 'mxchat_loops_section'
6843 );
6844
6845 add_settings_field(
6846 'triggered_phrase_response',
6847 esc_html__('Triggered Phrase Response', 'mxchat'),
6848 array($this, 'mxchat_triggered_phrase_response_callback'),
6849 'mxchat-embed',
6850 'mxchat_loops_section'
6851 );
6852
6853 add_settings_field(
6854 'email_capture_response',
6855 esc_html__('Email Capture Response', 'mxchat'),
6856 array($this, 'mxchat_email_capture_response_callback'),
6857 'mxchat-embed',
6858 'mxchat_loops_section'
6859 );
6860
6861 // Brave Search Settings Fields
6862 add_settings_section(
6863 'mxchat_brave_section',
6864 __('Brave Search Settings', 'mxchat'),
6865 array($this, 'mxchat_brave_section_callback'),
6866 'mxchat-embed'
6867 );
6868
6869 // Brave API Key moved to API Keys tab
6870 add_settings_field(
6871 'brave_image_count',
6872 __('Number of Images to Return', 'mxchat'),
6873 array($this, 'mxchat_brave_image_count_callback'),
6874 'mxchat-embed',
6875 'mxchat_brave_section'
6876 );
6877
6878 add_settings_field(
6879 'brave_safe_search',
6880 __('Safe Search', 'mxchat'),
6881 array($this, 'mxchat_brave_safe_search_callback'),
6882 'mxchat-embed',
6883 'mxchat_brave_section'
6884 );
6885
6886 add_settings_field(
6887 'brave_news_count',
6888 __('Number of News Articles', 'mxchat'),
6889 array($this, 'mxchat_brave_news_count_callback'),
6890 'mxchat-embed',
6891 'mxchat_brave_section'
6892 );
6893
6894 add_settings_field(
6895 'brave_country',
6896 __('Country', 'mxchat'),
6897 array($this, 'mxchat_brave_country_callback'),
6898 'mxchat-embed',
6899 'mxchat_brave_section'
6900 );
6901
6902 add_settings_field(
6903 'brave_language',
6904 __('Language', 'mxchat'),
6905 array($this, 'mxchat_brave_language_callback'),
6906 'mxchat-embed',
6907 'mxchat_brave_section'
6908 );
6909
6910 // Chat with PDF Intent Settings Fields
6911 add_settings_section(
6912 'mxchat_pdf_intent_section',
6913 __('Toolbar Settings & Intents', 'mxchat'),
6914 array($this, 'mxchat_pdf_intent_section_callback'),
6915 'mxchat-embed'
6916 );
6917
6918 add_settings_field(
6919 'chat_toolbar_toggle',
6920 __('Show Chat Toolbar', 'mxchat'),
6921 array($this, 'mxchat_chat_toolbar_toggle_callback'),
6922 'mxchat-embed',
6923 'mxchat_pdf_intent_section'
6924 );
6925
6926 // PDF Upload Button Toggle
6927 add_settings_field(
6928 'show_pdf_upload_button',
6929 __('Show PDF Upload Button', 'mxchat'),
6930 array($this, 'mxchat_show_pdf_upload_button_callback'),
6931 'mxchat-embed',
6932 'mxchat_pdf_intent_section'
6933 );
6934
6935 // Word Upload Button Toggle
6936 add_settings_field(
6937 'show_word_upload_button',
6938 __('Show Word Upload Button', 'mxchat'),
6939 array($this, 'mxchat_show_word_upload_button_callback'),
6940 'mxchat-embed',
6941 'mxchat_pdf_intent_section'
6942 );
6943
6944 add_settings_field(
6945 'pdf_intent_trigger_text',
6946 __('Intent Trigger Text', 'mxchat'),
6947 array($this, 'mxchat_pdf_intent_trigger_text_callback'),
6948 'mxchat-embed',
6949 'mxchat_pdf_intent_section'
6950 );
6951
6952 add_settings_field(
6953 'pdf_intent_success_text',
6954 __('Success Text', 'mxchat'),
6955 array($this, 'mxchat_pdf_intent_success_text_callback'),
6956 'mxchat-embed',
6957 'mxchat_pdf_intent_section'
6958 );
6959
6960 add_settings_field(
6961 'pdf_intent_error_text',
6962 __('Error Text', 'mxchat'),
6963 array($this, 'mxchat_pdf_intent_error_text_callback'),
6964 'mxchat-embed',
6965 'mxchat_pdf_intent_section'
6966 );
6967
6968 // Add PDF Maximum Pages Field
6969 add_settings_field(
6970 'pdf_max_pages',
6971 __('Maximum Document Pages', 'mxchat'),
6972 array($this, 'mxchat_pdf_max_pages_callback'),
6973 'mxchat-embed',
6974 'mxchat_pdf_intent_section'
6975 );
6976
6977 // Live Agent Settings Fields
6978 add_settings_section(
6979 'mxchat_live_agent_section',
6980 __('Live Agent Settings', 'mxchat'),
6981 array($this, 'mxchat_live_agent_section_callback'),
6982 'mxchat-embed'
6983 );
6984
6985 // Live Agent Status Fields (add at top of live agent settings)
6986 add_settings_field(
6987 'live_agent_status',
6988 __('Live Agent Status', 'mxchat'),
6989 array($this, 'mxchat_live_agent_status_callback'),
6990 'mxchat-embed',
6991 'mxchat_live_agent_section'
6992 );
6993
6994 // Slack availability schedule (plans 8ccaa2 + 99d7a4). Each handoff channel
6995 // owns an independent schedule rendered under its own Integrations tab; this
6996 // one governs Slack only. Same callback as Telegram's, parameterized.
6997 add_settings_field(
6998 'live_agent_schedule_slack',
6999 __('Availability Schedule', 'mxchat'),
7000 array($this, 'mxchat_live_agent_schedule_callback'),
7001 'mxchat-embed',
7002 'mxchat_live_agent_section',
7003 array('channel' => 'slack')
7004 );
7005
7006 add_settings_field(
7007 'live_agent_notification_message',
7008 __('Notification Message', 'mxchat'),
7009 array($this, 'mxchat_live_agent_notification_message_callback'),
7010 'mxchat-embed',
7011 'mxchat_live_agent_section'
7012 );
7013
7014 add_settings_field(
7015 'live_agent_away_message',
7016 __('Away Message', 'mxchat'),
7017 array($this, 'mxchat_live_agent_away_message_callback'),
7018 'mxchat-embed',
7019 'mxchat_live_agent_section'
7020 );
7021
7022 add_settings_field(
7023 'live_agent_user_ids',
7024 __('Slack Agent User IDs', 'mxchat'),
7025 array($this, 'mxchat_live_agent_user_ids_callback'),
7026 'mxchat-embed',
7027 'mxchat_live_agent_section'
7028 );
7029
7030 // Shared handoff channel (plan 9f7756): route every handoff into one
7031 // pre-existing channel as threads instead of creating chat-* channels.
7032 add_settings_field(
7033 'live_agent_shared_channel',
7034 __('Shared Handoff Channel', 'mxchat'),
7035 array($this, 'mxchat_live_agent_shared_channel_callback'),
7036 'mxchat-embed',
7037 'mxchat_live_agent_section'
7038 );
7039
7040 // Auto-archive per-conversation chat- channels on !endchat (plan 7458a7).
7041 // Default OFF; never touches the shared handoff channel.
7042 add_settings_field(
7043 'live_agent_archive_on_end_toggle',
7044 __('Archive Channel When Chat Ends', 'mxchat'),
7045 array($this, 'mxchat_live_agent_archive_on_end_callback'),
7046 'mxchat-embed',
7047 'mxchat_live_agent_section'
7048 );
7049
7050 add_settings_field(
7051 'live_agent_webhook_url',
7052 __('Slack Webhook URL', 'mxchat'),
7053 array($this, 'mxchat_live_agent_webhook_url_callback'),
7054 'mxchat-embed',
7055 'mxchat_live_agent_section'
7056 );
7057
7058 add_settings_field(
7059 'live_agent_secret_key',
7060 __('Slack Secret Key', 'mxchat'),
7061 array($this, 'mxchat_live_agent_secret_key_callback'),
7062 'mxchat-embed',
7063 'mxchat_live_agent_section'
7064 );
7065
7066 // Live Agent Integration Fields
7067 add_settings_field(
7068 'live_agent_bot_token',
7069 __('Slack Bot OAuth Token', 'mxchat'),
7070 array($this, 'mxchat_live_agent_bot_token_callback'),
7071 'mxchat-embed',
7072 'mxchat_live_agent_section'
7073 );
7074
7075 // Telegram Integration Section
7076 add_settings_section(
7077 'mxchat_telegram_section',
7078 __('Telegram Settings', 'mxchat'),
7079 array($this, 'mxchat_telegram_section_callback'),
7080 'mxchat-embed'
7081 );
7082
7083 add_settings_field(
7084 'telegram_status',
7085 __('Live Agent Status', 'mxchat'),
7086 array($this, 'mxchat_telegram_status_callback'),
7087 'mxchat-embed',
7088 'mxchat_telegram_section'
7089 );
7090
7091 // Telegram availability schedule (plan 99d7a4) — independent of Slack's,
7092 // rendered right under the Telegram status toggle it extends.
7093 add_settings_field(
7094 'live_agent_schedule_telegram',
7095 __('Availability Schedule', 'mxchat'),
7096 array($this, 'mxchat_live_agent_schedule_callback'),
7097 'mxchat-embed',
7098 'mxchat_telegram_section',
7099 array('channel' => 'telegram')
7100 );
7101
7102 add_settings_field(
7103 'telegram_notification_message',
7104 __('Notification Message', 'mxchat'),
7105 array($this, 'mxchat_telegram_notification_message_callback'),
7106 'mxchat-embed',
7107 'mxchat_telegram_section'
7108 );
7109
7110 add_settings_field(
7111 'telegram_away_message',
7112 __('Away Message', 'mxchat'),
7113 array($this, 'mxchat_telegram_away_message_callback'),
7114 'mxchat-embed',
7115 'mxchat_telegram_section'
7116 );
7117
7118 add_settings_field(
7119 'telegram_bot_token',
7120 __('Telegram Bot Token', 'mxchat'),
7121 array($this, 'mxchat_telegram_bot_token_callback'),
7122 'mxchat-embed',
7123 'mxchat_telegram_section'
7124 );
7125
7126 add_settings_field(
7127 'telegram_group_id',
7128 __('Telegram Group ID', 'mxchat'),
7129 array($this, 'mxchat_telegram_group_id_callback'),
7130 'mxchat-embed',
7131 'mxchat_telegram_section'
7132 );
7133
7134 add_settings_field(
7135 'telegram_webhook_secret',
7136 __('Webhook Secret Token', 'mxchat'),
7137 array($this, 'mxchat_telegram_webhook_secret_callback'),
7138 'mxchat-embed',
7139 'mxchat_telegram_section'
7140 );
7141
7142 // Webhook Handoff Section (plan d88e22) — the third live-agent destination.
7143 // Outbound-only: handoffs POST to the owner's URL; the chat stays in AI mode.
7144 add_settings_section(
7145 'mxchat_webhook_handoff_section',
7146 __('Webhook Handoff Settings', 'mxchat'),
7147 array($this, 'mxchat_webhook_handoff_section_callback'),
7148 'mxchat-embed'
7149 );
7150
7151 add_settings_field(
7152 'webhook_handoff_status',
7153 __('Live Agent Status', 'mxchat'),
7154 array($this, 'mxchat_webhook_handoff_status_callback'),
7155 'mxchat-embed',
7156 'mxchat_webhook_handoff_section'
7157 );
7158
7159 // Webhook availability schedule — independent of Slack's and Telegram's,
7160 // same parameterized editor (plans 8ccaa2 + 99d7a4 + d88e22).
7161 add_settings_field(
7162 'live_agent_schedule_webhook',
7163 __('Availability Schedule', 'mxchat'),
7164 array($this, 'mxchat_live_agent_schedule_callback'),
7165 'mxchat-embed',
7166 'mxchat_webhook_handoff_section',
7167 array('channel' => 'webhook')
7168 );
7169
7170 add_settings_field(
7171 'webhook_handoff_notification_message',
7172 __('Notification Message', 'mxchat'),
7173 array($this, 'mxchat_webhook_handoff_notification_message_callback'),
7174 'mxchat-embed',
7175 'mxchat_webhook_handoff_section'
7176 );
7177
7178 add_settings_field(
7179 'webhook_handoff_away_message',
7180 __('Away Message', 'mxchat'),
7181 array($this, 'mxchat_webhook_handoff_away_message_callback'),
7182 'mxchat-embed',
7183 'mxchat_webhook_handoff_section'
7184 );
7185
7186 add_settings_field(
7187 'webhook_handoff_url',
7188 __('Webhook URL', 'mxchat'),
7189 array($this, 'mxchat_webhook_handoff_url_callback'),
7190 'mxchat-embed',
7191 'mxchat_webhook_handoff_section'
7192 );
7193
7194 add_settings_field(
7195 'webhook_handoff_secret',
7196 __('Signing Secret', 'mxchat'),
7197 array($this, 'mxchat_webhook_handoff_secret_callback'),
7198 'mxchat-embed',
7199 'mxchat_webhook_handoff_section'
7200 );
7201
7202 // General Settings Section
7203 add_settings_section(
7204 'mxchat_general_section',
7205 esc_html__('YouTube Tutorials', 'mxchat'),
7206 null,
7207 'mxchat-general'
7208 );
7209 }
7210
7211 public function mxchat_prompts_page_init() {
7212 register_setting(
7213 'mxchat_prompts_options',
7214 'mxchat_prompts_options',
7215 array(
7216 'type' => 'array',
7217 'description' => __('MXChat Knowledge Base Settings', 'mxchat'),
7218 'default' => array(
7219 'mxchat_auto_sync_posts' => 0,
7220 'mxchat_auto_sync_pages' => 0,
7221 'mxchat_use_pinecone' => 0,
7222 'mxchat_pinecone_api_key' => '',
7223 'mxchat_pinecone_environment' => '',
7224 'mxchat_pinecone_index' => '',
7225 'mxchat_pinecone_host' => '',
7226 ),
7227 'sanitize_callback' => array($this, 'sanitize_prompts_options'),
7228 )
7229 );
7230
7231 add_action('admin_notices', array($this, 'sync_settings_notice'));
7232 }
7233
7234 public function mxchat_transcripts_page_init() {
7235 register_setting(
7236 'mxchat_transcripts_options',
7237 'mxchat_transcripts_options',
7238 array(
7239 'type' => 'array',
7240 'description' => __('MXChat Transcripts Notification Settings', 'mxchat'),
7241 'default' => array(
7242 'mxchat_enable_notifications' => 0,
7243 'mxchat_notification_email' => get_option('admin_email'),
7244 'mxchat_auto_delete_transcripts' => 'never',
7245 ),
7246 'sanitize_callback' => array($this, 'sanitize_transcripts_options'),
7247 )
7248 );
7249
7250 add_settings_section(
7251 'mxchat_transcripts_notification_section',
7252 esc_html__('Chat Notification Settings', 'mxchat'),
7253 array($this, 'mxchat_transcripts_notification_section_callback'),
7254 'mxchat-transcripts'
7255 );
7256
7257 add_settings_field(
7258 'mxchat_enable_notifications',
7259 esc_html__('Enable Chat Notifications', 'mxchat'),
7260 array($this, 'mxchat_enable_notifications_callback'),
7261 'mxchat-transcripts',
7262 'mxchat_transcripts_notification_section'
7263 );
7264
7265 add_settings_field(
7266 'mxchat_notification_email',
7267 esc_html__('Notification Email Address', 'mxchat'),
7268 array($this, 'mxchat_notification_email_callback'),
7269 'mxchat-transcripts',
7270 'mxchat_transcripts_notification_section'
7271 );
7272
7273 add_settings_field(
7274 'mxchat_auto_delete_transcripts',
7275 esc_html__('Auto-Delete Old Transcripts', 'mxchat'),
7276 array($this, 'mxchat_auto_delete_transcripts_callback'),
7277 'mxchat-transcripts',
7278 'mxchat_transcripts_notification_section'
7279 );
7280
7281 add_settings_field(
7282 'mxchat_retention_days',
7283 esc_html__('Custom Retention (Days)', 'mxchat'),
7284 array($this, 'mxchat_retention_days_callback'),
7285 'mxchat-transcripts',
7286 'mxchat_transcripts_notification_section'
7287 );
7288
7289 add_settings_field(
7290 'mxchat_auto_email_transcript',
7291 esc_html__('Auto-Email Full Transcript', 'mxchat'),
7292 array($this, 'mxchat_auto_email_transcript_callback'),
7293 'mxchat-transcripts',
7294 'mxchat_transcripts_notification_section'
7295 );
7296 }
7297
7298
7299 /**
7300 * Sanitize all prompts options
7301 *
7302 * @param array $input The unsanitized options array
7303 * @return array The sanitized options array
7304 */
7305 public function sanitize_prompts_options($input) {
7306 // Log the incoming input.
7307 //error_log('Sanitizing inputs: ' . print_r($input, true));
7308
7309 $sanitized = array();
7310
7311 // Boolean options
7312 $sanitized['mxchat_auto_sync_posts'] = isset($input['mxchat_auto_sync_posts']) ? 1 : 0;
7313 $sanitized['mxchat_auto_sync_pages'] = isset($input['mxchat_auto_sync_pages']) ? 1 : 0;
7314 $sanitized['mxchat_use_pinecone'] = !empty($input['mxchat_use_pinecone']) ? 1 : 0;
7315
7316 // API Key: if less than 32 characters, flag as invalid.
7317 $api_key = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
7318 if (!empty($api_key) && strlen($api_key) < 32) {
7319 add_settings_error(
7320 'mxchat_prompts_options',
7321 'invalid_api_key',
7322 __('The Pinecone API key appears to be invalid. Please check your API key.', 'mxchat')
7323 );
7324 $existing_options = get_option('mxchat_prompts_options', array());
7325 $sanitized['mxchat_pinecone_api_key'] = $existing_options['mxchat_pinecone_api_key'] ?? '';
7326 } else {
7327 $sanitized['mxchat_pinecone_api_key'] = $api_key;
7328 }
7329
7330 // Environment and Index Name
7331 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
7332 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
7333
7334 // Host: Remove protocol and validate format.
7335 $host = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
7336 $host = preg_replace('#^https?://#', '', $host);
7337 //error_log('Host after removing protocol: ' . $host);
7338 if (!empty($host)) {
7339 if (!preg_match('/^[\w-]+\.svc\.[\w-]+\.pinecone\.io$/', $host)) {
7340 add_settings_error(
7341 'mxchat_prompts_options',
7342 'invalid_host',
7343 __('The Pinecone host appears to be invalid. It should look like "mxchat-vectors-zrmsquq.svc.aped-4627-b74a.pinecone.io"', 'mxchat')
7344 );
7345 $existing_options = get_option('mxchat_prompts_options', array());
7346 $sanitized['mxchat_pinecone_host'] = $existing_options['mxchat_pinecone_host'] ?? '';
7347 } else {
7348 $sanitized['mxchat_pinecone_host'] = $host;
7349 }
7350 } else {
7351 $sanitized['mxchat_pinecone_host'] = '';
7352 }
7353
7354 //error_log('Final sanitized array: ' . print_r($sanitized, true));
7355
7356 return $sanitized;
7357 }
7358
7359 public function sync_settings_notice() {
7360 // Only show notice on our plugin page
7361 if (!isset($_GET['page']) || $_GET['page'] !== 'mxchat-prompts') {
7362 return;
7363 }
7364
7365 // Check if settings were updated
7366 if (isset($_GET['settings-updated'])) {
7367
7368 ?>
7369 <div class="notice notice-success is-dismissible">
7370 <p><?php esc_html_e('Sync settings updated successfully.', 'mxchat'); ?></p>
7371 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php esc_html_e('Dismiss this notice.', 'mxchat'); ?></span></button>
7372 </div>
7373 <?php
7374
7375 }
7376 }
7377 // Add this sanitization function to your class
7378 public function sanitize_sync_setting($input) {
7379 return (bool)$input ? __('1', 'mxchat') : __('', 'mxchat');
7380 }
7381
7382 public function mxchat_rate_limits_callback() {
7383 $all_options = get_option('mxchat_options', []);
7384
7385 // Define available rate limits
7386 $rate_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
7387
7388 // Define available timeframes
7389 $timeframes = array(
7390 'hourly' => __('Per Hour', 'mxchat'),
7391 'daily' => __('Per Day', 'mxchat'),
7392 'weekly' => __('Per Week', 'mxchat'),
7393 'monthly' => __('Per Month', 'mxchat')
7394 );
7395
7396 // Get all roles plus a "logged_out" pseudo-role
7397 $roles = wp_roles()->get_names();
7398 $roles['logged_out'] = __('Logged Out Users', 'mxchat');
7399
7400 // Start the wrapper
7401 echo '<div class="pro-feature-wrapper active">';
7402 echo '<div class="mxchat-rate-limits-container">';
7403
7404 echo '<p class="description" style="margin-bottom: 20px;">' .
7405 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') .
7406 '</p>';
7407
7408 // Add markdown link documentation
7409 echo '<div class="notice notice-info inline" style="margin-bottom: 20px; padding: 10px;">';
7410 echo '<p><strong>' . esc_html__('Markdown Links Supported:', 'mxchat') . '</strong></p>';
7411 echo '<p>' . esc_html__('You can include clickable links in your custom messages using markdown syntax:', 'mxchat') . '</p>';
7412 echo '<ul style="margin-left: 20px;">';
7413 echo '<li><code>[Link text](https://example.com)</code> - Creates a clickable link</li>';
7414 echo '<li><code>[Visit our pricing](https://example.com/pricing)</code> - Link with custom text</li>';
7415 echo '<li><code>Plain URLs like https://example.com will also become clickable</code></li>';
7416 echo '</ul>';
7417 echo '</div>';
7418
7419 // Output the controls for each role
7420 foreach ($roles as $role_id => $role_name) {
7421 // Get saved options or defaults
7422 $default_limit = ($role_id === 'logged_out') ? '10' : '100';
7423 $default_timeframe = 'daily';
7424 $default_message = __('Rate limit exceeded. Please try again later.', 'mxchat');
7425
7426 $selected_limit = isset($all_options['rate_limits'][$role_id]['limit'])
7427 ? $all_options['rate_limits'][$role_id]['limit']
7428 : $default_limit;
7429
7430 $selected_timeframe = isset($all_options['rate_limits'][$role_id]['timeframe'])
7431 ? $all_options['rate_limits'][$role_id]['timeframe']
7432 : $default_timeframe;
7433
7434 $custom_message = isset($all_options['rate_limits'][$role_id]['message'])
7435 ? $all_options['rate_limits'][$role_id]['message']
7436 : $default_message;
7437
7438 // Output the row
7439 echo '<div class="mxchat-rate-limit-row mxchat-autosave-section">';
7440
7441 // Role label
7442 echo '<div class="mxchat-rate-limit-role">' . esc_html($role_name) . '</div>';
7443
7444 // Controls section
7445 echo '<div class="mxchat-rate-limit-controls-wrapper">';
7446
7447 // Rate limit and timeframe controls
7448 echo '<div class="mxchat-rate-limit-controls">';
7449
7450 // Limit dropdown
7451 echo '<div>';
7452 echo '<label for="rate_limits_' . esc_attr($role_id) . '_limit">' . esc_html__('Limit:', 'mxchat') . '</label>';
7453 echo '<select
7454 id="rate_limits_' . esc_attr($role_id) . '_limit"
7455 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][limit]"
7456 class="mxchat-autosave-field">';
7457 foreach ($rate_limits as $limit) {
7458 echo '<option value="' . esc_attr($limit) . '" ' . selected($selected_limit, $limit, false) . '>' . esc_html($limit) . '</option>';
7459 }
7460 echo '</select>';
7461 echo '</div>';
7462
7463 // Timeframe dropdown
7464 echo '<div>';
7465 echo '<label for="rate_limits_' . esc_attr($role_id) . '_timeframe">' . esc_html__('Timeframe:', 'mxchat') . '</label>';
7466 echo '<select
7467 id="rate_limits_' . esc_attr($role_id) . '_timeframe"
7468 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][timeframe]"
7469 class="mxchat-autosave-field">';
7470 foreach ($timeframes as $value => $label) {
7471 echo '<option value="' . esc_attr($value) . '" ' . selected($selected_timeframe, $value, false) . '>' . esc_html($label) . '</option>';
7472 }
7473 echo '</select>';
7474 echo '</div>';
7475
7476 echo '</div>'; // End controls
7477
7478 // Custom message textarea
7479 echo '<div class="mxchat-rate-limit-message">';
7480 echo '<label for="rate_limits_' . esc_attr($role_id) . '_message">' . esc_html__('Custom Message:', 'mxchat') . '</label>';
7481 echo '<textarea
7482 id="rate_limits_' . esc_attr($role_id) . '_message"
7483 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][message]"
7484 class="mxchat-autosave-field"
7485 placeholder="' . esc_attr__('Enter custom message when rate limit is exceeded', 'mxchat') . '">' .
7486 esc_textarea($custom_message) .
7487 '</textarea>';
7488 echo '<p class="description">' .
7489 esc_html__('Example: Rate limit reached! [Visit our pricing page](https://example.com/pricing) to upgrade.', 'mxchat') .
7490 '</p>';
7491 echo '</div>'; // End message
7492
7493 echo '</div>'; // End controls wrapper
7494
7495 echo '</div>'; // End row
7496 }
7497
7498 echo '</div>'; // End container
7499
7500 echo '</div>'; // End pro-feature-wrapper
7501 }
7502
7503 private function mxchat_add_option_field($id, $title, $callback = '') {
7504 add_settings_field(
7505 $id,
7506 __($title, 'mxchat'),
7507 $callback ? array($this, $callback) : array($this, $id . '_callback'),
7508 'mxchat-max',
7509 'mxchat_setting_section_id',
7510 $id === 'model' ? ['label_for' => 'model'] : []
7511 );
7512 }
7513
7514 // API Keys Section Callback
7515 public function mxchat_api_keys_section_callback() {
7516 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>';
7517 }
7518
7519 // OpenAI API Key
7520 public function api_key_callback() {
7521 $apiKey = isset($this->options['api_key']) ? esc_attr($this->options['api_key']) : '';
7522 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7523
7524 echo '<div class="api-key-wrapper">';
7525 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 . '" />';
7526 echo '<button type="button" id="toggleApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7527 echo '<p class="description">' . esc_html__('Required for OpenAI GPT models and OpenAI embeddings. Get your API key from OpenAI Platform.', 'mxchat') . '</p>';
7528 echo $this->mxchat_provider_key_test_button('openai', 'api_key');
7529 echo '</div>';
7530 }
7531
7532 // X.AI API Key
7533 public function xai_api_key_callback() {
7534 $xaiApiKey = isset($this->options['xai_api_key']) ? esc_attr($this->options['xai_api_key']) : '';
7535 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7536
7537 echo '<div class="api-key-wrapper">';
7538 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 . '" />';
7539 echo '<button type="button" id="toggleXaiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7540 echo '<p class="description">' . esc_html__('Required for X.AI Grok models. Get your API key from X.AI Console.', 'mxchat') . '</p>';
7541 echo $this->mxchat_provider_key_test_button('xai', 'xai_api_key');
7542 echo '</div>';
7543 }
7544 // Claude API Key
7545 public function claude_api_key_callback() {
7546 $claudeApiKey = isset($this->options['claude_api_key']) ? esc_attr($this->options['claude_api_key']) : '';
7547 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7548
7549 echo '<div class="api-key-wrapper">';
7550 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 . '" />';
7551 echo '<button type="button" id="toggleClaudeApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7552 echo '<p class="description">' . esc_html__('Required for Anthropic Claude models. Get your API key from Anthropic Console.', 'mxchat') . '</p>';
7553 echo $this->mxchat_provider_key_test_button('claude', 'claude_api_key');
7554 echo '</div>';
7555 }
7556
7557 // DeepSeek API Key
7558 public function deepseek_api_key_callback() {
7559 $apiKey = isset($this->options['deepseek_api_key']) ? esc_attr($this->options['deepseek_api_key']) : '';
7560 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7561
7562 echo '<div class="api-key-wrapper">';
7563 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 . '" />';
7564 echo '<button type="button" id="toggleDeepSeekApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7565 echo '<p class="description">' . esc_html__('Required for DeepSeek models. Get your API key from DeepSeek Platform.', 'mxchat') . '</p>';
7566 echo $this->mxchat_provider_key_test_button('deepseek', 'deepseek_api_key');
7567 echo '</div>';
7568 }
7569
7570 // Gemini API Key
7571 public function gemini_api_key_callback() {
7572 $geminiApiKey = isset($this->options['gemini_api_key']) ? esc_attr($this->options['gemini_api_key']) : '';
7573 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7574
7575 echo '<div class="api-key-wrapper">';
7576 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 . '" />';
7577 echo '<button type="button" id="toggleGeminiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7578 echo '<p class="description">' . esc_html__('Required for Google Gemini models and embeddings. Get your API key from Google AI Studio.', 'mxchat') . '</p>';
7579 echo $this->mxchat_provider_key_test_button('gemini', 'gemini_api_key');
7580 echo '</div>';
7581 }
7582
7583
7584 // OpenRouter API Key
7585 public function openrouter_api_key_callback() {
7586 $openrouterApiKey = isset($this->options['openrouter_api_key']) ? esc_attr($this->options['openrouter_api_key']) : '';
7587 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7588
7589 echo '<div class="api-key-wrapper">';
7590 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 . '" />';
7591 echo '<button type="button" id="toggleOpenRouterApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7592 echo '<p class="description">' . esc_html__('Required for OpenRouter models. Get your API key from OpenRouter.ai', 'mxchat') . '</p>';
7593 echo $this->mxchat_provider_key_test_button('openrouter', 'openrouter_api_key');
7594 echo '</div>';
7595 }
7596
7597 /**
7598 * Renders a "Test key" button + result target next to a built-in provider key
7599 * field, and emits the shared delegated click handler ONCE (static guard). The
7600 * button carries data-target = the key field id so the owner can test the value
7601 * they just typed (test-before-save); the AJAX handler falls back to the saved
7602 * key when the field is empty. Styled with the WP `button` class to match the
7603 * adjacent Custom-provider "Test Connection" button on this same page.
7604 * plan-mxchat-20260623-c41f74.
7605 */
7606 public function mxchat_provider_key_test_button($provider, $target_id) {
7607 static $script_emitted = false;
7608 $nonce = wp_create_nonce('mxchat_test_provider_key');
7609 $html = '<div class="mxchat-key-test" style="margin-top:8px;">'
7610 . '<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>'
7611 . '<span class="mxchat-test-provider-key-result" style="margin-left:10px;font-size:13px;vertical-align:middle;"></span>'
7612 . '</div>';
7613 if (!$script_emitted) {
7614 $script_emitted = true;
7615 $html .= $this->mxchat_provider_key_test_script();
7616 }
7617 return $html;
7618 }
7619
7620 /**
7621 * One-time delegated click handler shared by every .mxchat-test-provider-key
7622 * button. Posts the typed key value + provider to mxchat_test_provider_key and
7623 * renders the success/error message inline. Mirrors the Custom-provider test.
7624 */
7625 private function mxchat_provider_key_test_script() {
7626 $t_testing = esc_js(__('Testing...', 'mxchat'));
7627 $t_valid = esc_js(__('Key is valid.', 'mxchat'));
7628 $t_failed = esc_js(__('Failed', 'mxchat'));
7629 $t_req = esc_js(__('Request failed', 'mxchat'));
7630 return '<script>(function(){'
7631 . 'if (window.__mxchatProviderKeyTestWired) { return; }'
7632 . 'window.__mxchatProviderKeyTestWired = true;'
7633 . 'document.addEventListener("click", function(e){'
7634 . 'var btn = e.target && e.target.closest ? e.target.closest(".mxchat-test-provider-key") : null;'
7635 . 'if (!btn) { return; }'
7636 . 'e.preventDefault();'
7637 . 'var field = btn.getAttribute("data-target") ? document.getElementById(btn.getAttribute("data-target")) : null;'
7638 . 'var out = btn.parentNode ? btn.parentNode.querySelector(".mxchat-test-provider-key-result") : null;'
7639 . 'if (out) { out.textContent = "' . $t_testing . '"; out.style.color = "#646970"; }'
7640 . 'btn.disabled = true;'
7641 . 'var fd = new FormData();'
7642 . 'fd.append("action", "mxchat_test_provider_key");'
7643 . 'fd.append("_wpnonce", btn.getAttribute("data-nonce"));'
7644 . 'fd.append("provider", btn.getAttribute("data-provider") || "");'
7645 . 'fd.append("key", field ? field.value : "");'
7646 . 'fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })'
7647 . '.then(function(r){ return r.json(); })'
7648 . '.then(function(j){'
7649 . 'if (out) {'
7650 . 'if (j && j.success) { out.textContent = "✓ " + ((j.data && j.data.message) ? j.data.message : "' . $t_valid . '"); out.style.color = "#00a32a"; }'
7651 . 'else { out.textContent = "⚠ " + ((j && j.data && j.data.message) ? j.data.message : "' . $t_failed . '"); out.style.color = "#d63638"; }'
7652 . '}'
7653 . 'btn.disabled = false;'
7654 . '})'
7655 . '.catch(function(){ if (out) { out.textContent = "⚠ ' . $t_req . '"; out.style.color = "#d63638"; } btn.disabled = false; });'
7656 . '});'
7657 . '})();</script>';
7658 }
7659
7660 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
7661 public function custom_provider_callback() {
7662 $base_url = isset($this->options['custom_provider_base_url']) ? esc_attr($this->options['custom_provider_base_url']) : '';
7663 $api_key = isset($this->options['custom_provider_api_key']) ? esc_attr($this->options['custom_provider_api_key']) : '';
7664 $model_name = isset($this->options['custom_provider_model']) ? esc_attr($this->options['custom_provider_model']) : '';
7665 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? esc_attr($this->options['custom_provider_auth_scheme']) : 'bearer';
7666 $api_version = isset($this->options['custom_provider_api_version']) ? esc_attr($this->options['custom_provider_api_version']) : '';
7667 $use_embed = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
7668 $use_images = !empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on';
7669 $embed_model = isset($this->options['custom_provider_embedding_model']) ? esc_attr($this->options['custom_provider_embedding_model']) : '';
7670 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7671 $test_nonce = wp_create_nonce('mxchat_test_custom_provider');
7672
7673 echo '<style>
7674 .mxchat-cp { max-width: 680px; }
7675 .mxchat-cp .mxchat-cp-intro { margin: 0 0 16px; color: #50575e; font-size: 13px; line-height: 1.5; }
7676 .mxchat-cp .mxchat-cp-row { display: block; margin: 0 0 18px; }
7677 .mxchat-cp .mxchat-cp-row > label { display: block; font-weight: 600; margin: 0 0 6px; color: #1d2327; font-size: 13px; }
7678 .mxchat-cp .mxchat-cp-row > input[type="text"],
7679 .mxchat-cp .mxchat-cp-row > input[type="password"],
7680 .mxchat-cp .mxchat-cp-row > select { display: block; width: 100%; max-width: 480px; margin: 0; }
7681 .mxchat-cp .mxchat-cp-row > .description { display: block; margin: 6px 0 0; color: #646970; font-size: 12px; line-height: 1.5; max-width: 480px; }
7682 .mxchat-cp .mxchat-cp-test { margin-top: 4px; padding-top: 14px; border-top: 1px solid #e5e7eb; }
7683 .mxchat-cp .mxchat-cp-test .mxchat-cp-test-status { display: inline-block; margin-left: 10px; vertical-align: middle; font-size: 13px; }
7684 .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; }
7685 .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; }
7686 .mxchat-cp .mxchat-cp-azure ol { margin: 0; padding: 0 0 0 18px; color: #50575e; font-size: 12px; line-height: 1.7; }
7687 .mxchat-cp .mxchat-cp-azure code { background: #eceefb; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
7688 </style>';
7689
7690 echo '<div class="api-key-wrapper mxchat-cp">';
7691 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>';
7692
7693 // Azure OpenAI quick start — consolidates the 4-field Azure recipe in one scannable callout
7694 // so admins can configure Azure without piecing it together from each field's hint.
7695 echo '<div class="mxchat-cp-azure">';
7696 echo '<span class="mxchat-cp-azure-title">' . esc_html__('Azure OpenAI quick start', 'mxchat') . '</span>';
7697 echo '<ol>';
7698 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>';
7699 echo '<li>' . wp_kses(__('<strong>API Key</strong> → your Azure OpenAI key (required)', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7700 echo '<li>' . wp_kses(__('<strong>Auth Scheme</strong> → <code>api-key header (Azure OpenAI)</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7701 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>';
7702 echo '</ol>';
7703 echo '</div>';
7704
7705 echo '<div class="mxchat-cp-row">';
7706 echo '<label for="custom_provider_base_url">' . esc_html__('Base URL', 'mxchat') . '</label>';
7707 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 . '" />';
7708 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>';
7709 echo '</div>';
7710
7711 echo '<div class="mxchat-cp-row">';
7712 echo '<label for="custom_provider_api_key">' . esc_html__('API Key (optional)', 'mxchat') . '</label>';
7713 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 . '" />';
7714 echo '<p class="description">' . esc_html__('Leave empty for unauthenticated local servers. Required for Azure / vLLM / hosted endpoints.', 'mxchat') . '</p>';
7715 echo '</div>';
7716
7717 echo '<div class="mxchat-cp-row">';
7718 echo '<label for="custom_provider_model">' . esc_html__('Model Name', 'mxchat') . '</label>';
7719 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 . '" />';
7720 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>';
7721 echo '</div>';
7722
7723 echo '<div class="mxchat-cp-row">';
7724 echo '<label for="custom_provider_auth_scheme">' . esc_html__('Auth Scheme', 'mxchat') . '</label>';
7725 echo '<select id="custom_provider_auth_scheme" name="custom_provider_auth_scheme" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7726 echo '<option value="bearer"' . selected($auth_scheme, 'bearer', false) . '>' . esc_html__('Authorization: Bearer (OpenAI / Ollama / vLLM / LM Studio)', 'mxchat') . '</option>';
7727 echo '<option value="api-key"' . selected($auth_scheme, 'api-key', false) . '>' . esc_html__('api-key header (Azure OpenAI)', 'mxchat') . '</option>';
7728 echo '</select>';
7729 echo '<p class="description">' . esc_html__('Most OpenAI-compatible servers use Bearer. Azure OpenAI uses the api-key header.', 'mxchat') . '</p>';
7730 echo '</div>';
7731
7732 echo '<div class="mxchat-cp-row">';
7733 echo '<label for="custom_provider_api_version">' . esc_html__('API Version (Azure only)', 'mxchat') . '</label>';
7734 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 . '" />';
7735 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>';
7736 echo '</div>';
7737
7738 // Extended-use checkboxes — opt-in routing of other dispatcher paths through the custom provider.
7739 echo '<div class="mxchat-cp-row">';
7740 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>';
7741 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>';
7742 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>';
7743 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>';
7744 echo '</div>';
7745
7746 echo '<div class="mxchat-cp-row">';
7747 echo '<label for="custom_provider_embedding_model">' . esc_html__('Custom Embedding Model', 'mxchat') . '</label>';
7748 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 . '" />';
7749 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>';
7750 echo '</div>';
7751
7752 echo '<div class="mxchat-cp-test">';
7753 echo '<button type="button" class="button" id="mxchat-test-custom-provider" data-nonce="' . $test_nonce . '">' . esc_html__('Test Connection', 'mxchat') . '</button>';
7754 echo '<span id="mxchat-test-custom-provider-result" class="mxchat-cp-test-status"></span>';
7755 echo '</div>';
7756
7757 echo '<script>(function(){
7758 var btn = document.getElementById("mxchat-test-custom-provider");
7759 if (!btn || btn._wired) { return; } btn._wired = true;
7760 btn.addEventListener("click", function(){
7761 var out = document.getElementById("mxchat-test-custom-provider-result");
7762 out.textContent = "' . esc_js(__('Testing...', 'mxchat')) . '";
7763 out.style.color = "#646970";
7764 var fd = new FormData();
7765 fd.append("action", "mxchat_test_custom_provider");
7766 fd.append("_wpnonce", btn.getAttribute("data-nonce"));
7767 fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })
7768 .then(function(r){ return r.json(); })
7769 .then(function(j){
7770 if (j && j.success) {
7771 out.textContent = "✓ " + (j.data && j.data.message ? j.data.message : "' . esc_js(__('OK', 'mxchat')) . '");
7772 out.style.color = "#00a32a";
7773 } else {
7774 out.textContent = "⚠ " + (j && j.data && j.data.message ? j.data.message : "' . esc_js(__('Failed', 'mxchat')) . '");
7775 out.style.color = "#d63638";
7776 }
7777 })
7778 .catch(function(){
7779 out.textContent = "⚠ ' . esc_js(__('Request failed', 'mxchat')) . '";
7780 out.style.color = "#d63638";
7781 });
7782 });
7783 })();</script>';
7784
7785 echo '</div>';
7786 }
7787
7788 // Voyage API Key
7789 public function voyage_api_key_callback() {
7790 $apiKey = isset($this->options['voyage_api_key']) ? esc_attr($this->options['voyage_api_key']) : '';
7791 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7792
7793 echo '<div class="api-key-wrapper">';
7794 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 . '" />';
7795 echo '<button type="button" id="toggleVoyageAPIKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7796 echo '<p class="description">' . esc_html__('Required for Voyage AI embedding models. Get your API key from Voyage AI.', 'mxchat') . '</p>';
7797 echo '</div>';
7798 }
7799
7800 public function mxchat_loops_api_key_callback() {
7801 $loops_api_key = isset($this->options['loops_api_key']) ? esc_attr($this->options['loops_api_key']) : '';
7802 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7803
7804 echo '<div class="api-key-wrapper">';
7805 echo sprintf(
7806 '<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" />',
7807 $loops_api_key,
7808 $nonce
7809 );
7810 echo '<button type="button" id="toggleLoopsApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7811 echo '</div>';
7812 // Cross-reference back to where the list is chosen, so the two screens point
7813 // at each other (plan-mxchat-20260802-907a63).
7814 echo '<p class="description">' . wp_kses(
7815 sprintf(
7816 /* translators: %s: link to the Loops integration tab. */
7817 __('Required for Loops email integration. Get your API key from Loops.so, then choose your mailing list under %s.', 'mxchat'),
7818 '<a href="#integrations-loops">' . esc_html__('Integrations, Loops', 'mxchat') . '</a>'
7819 ),
7820 self::mxchat_loops_pointer_allowed_html()
7821 ) . '</p>';
7822 }
7823 public function mxchat_loops_mailing_list_callback() {
7824 // Add error handling and type checking
7825 $loops_api_key = '';
7826 $selected_list = '';
7827 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7828
7829 // Safely get the API key
7830 if (isset($this->options['loops_api_key']) && is_string($this->options['loops_api_key'])) {
7831 $loops_api_key = $this->options['loops_api_key'];
7832 }
7833
7834 // Safely get the selected list
7835 if (isset($this->options['loops_mailing_list']) && is_string($this->options['loops_mailing_list'])) {
7836 $selected_list = $this->options['loops_mailing_list'];
7837 }
7838
7839 if (!empty($loops_api_key)) {
7840 $lists = $this->mxchat_fetch_loops_mailing_lists($loops_api_key);
7841 if (is_array($lists) && !empty($lists)) {
7842 echo '<div class="mxchat-field-wrapper">';
7843 echo '<select id="loops_mailing_list" name="loops_mailing_list" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7844
7845 // Add a default "Select a list" option
7846 echo '<option value="" ' . selected($selected_list, '', false) . '>' . esc_html__('Select a list', 'mxchat') . '</option>';
7847
7848 foreach ($lists as $list) {
7849 if (is_array($list) && isset($list['id']) && isset($list['name'])) {
7850 echo sprintf(
7851 '<option value="%s" %s>%s</option>',
7852 esc_attr($list['id']),
7853 selected($selected_list, $list['id'], false),
7854 esc_html($list['name'])
7855 );
7856 }
7857 }
7858 echo '</select>';
7859 echo '</div>';
7860 echo '<p class="description">' . esc_html__('Please select a mailing list to use with Loops.', 'mxchat') . '</p>';
7861 } else {
7862 echo '<p class="description">' . wp_kses(
7863 self::mxchat_loops_api_key_pointer(
7864 /* translators: %s: link to the API Keys tab. */
7865 __('No lists found. Please check your Loops API key under %s.', 'mxchat')
7866 ),
7867 self::mxchat_loops_pointer_allowed_html()
7868 ) . '</p>';
7869 }
7870 } else {
7871 echo '<p class="description">' . wp_kses(
7872 self::mxchat_loops_api_key_pointer(
7873 /* translators: %s: link to the API Keys tab. */
7874 __('Enter your Loops API key under %s to load mailing lists.', 'mxchat')
7875 ),
7876 self::mxchat_loops_pointer_allowed_html()
7877 ) . '</p>';
7878 }
7879 }
7880
7881 /**
7882 * Build the "API Keys" pointer used by the Loops mailing-list field.
7883 *
7884 * plan-mxchat-20260802-907a63. The Loops API key field was moved to the API
7885 * Keys tab, but this field's copy still read as though the input were beside
7886 * it, so users had nowhere to go. Both live on the SAME screen
7887 * (admin.php?page=mxchat-settings) — the key is under the API Keys tab, this
7888 * dropdown under Integrations > Loops — so the pointer is an in-page tab
7889 * switch, not a cross-page link.
7890 *
7891 * A plain href="#api-keys" is all it takes: the shared admin shell
7892 * (js/admin-sidebar.js) honours section-naming hashes on load AND on
7893 * hashchange (plan 4ede16), so ordinary anchors switch tabs — the old
7894 * data-target workaround on these pointer anchors is gone.
7895 *
7896 * @param string $template Translatable string containing one %s placeholder.
7897 * @return string
7898 */
7899 private static function mxchat_loops_api_key_pointer($template) {
7900 return sprintf(
7901 $template,
7902 '<a href="#api-keys">' . esc_html__('API Keys', 'mxchat') . '</a>'
7903 );
7904 }
7905
7906 /**
7907 * Allowed HTML for the Loops pointer — anchor plus the shell's tab-switch hook.
7908 */
7909 private static function mxchat_loops_pointer_allowed_html() {
7910 return array(
7911 'a' => array(
7912 'href' => array(),
7913 'data-target' => array(),
7914 ),
7915 );
7916 }
7917 public function mxchat_triggered_phrase_response_callback() {
7918 $default_response = __('Would you like to join our mailing list? Please provide your email below.', 'mxchat');
7919 $triggered_response = isset($this->options['triggered_phrase_response'])
7920 ? $this->options['triggered_phrase_response']
7921 : $default_response;
7922 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7923
7924 echo '<div class="mxchat-field-wrapper">';
7925 echo sprintf(
7926 '<textarea id="triggered_phrase_response" name="triggered_phrase_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
7927 $nonce,
7928 esc_textarea($triggered_response)
7929 );
7930 echo '</div>';
7931 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>';
7932 }
7933
7934 public function mxchat_email_capture_response_callback() {
7935 $default_response = __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
7936 $email_capture_response = isset($this->options['email_capture_response'])
7937 ? $this->options['email_capture_response']
7938 : $default_response;
7939 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7940
7941 echo '<div class="mxchat-field-wrapper">';
7942 echo sprintf(
7943 '<textarea id="email_capture_response" name="email_capture_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
7944 $nonce,
7945 esc_textarea($email_capture_response)
7946 );
7947 echo '</div>';
7948 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>';
7949 }
7950 public function mxchat_pre_chat_message_callback() {
7951 // Load the entire 'mxchat_options' array
7952 $all_options = get_option('mxchat_options', []);
7953
7954 // Retrieve the saved message or use the default value
7955 $default_message = __('Hey there! Ask me anything!', 'mxchat');
7956 $pre_chat_message = isset($all_options['pre_chat_message']) ? $all_options['pre_chat_message'] : $default_message;
7957
7958 // Output the textarea
7959 printf(
7960 '<textarea id="pre_chat_message" name="pre_chat_message" rows="5" cols="50">%s</textarea>',
7961 esc_textarea($pre_chat_message)
7962 );
7963 }
7964
7965 // Callback for AI Instructions textarea
7966 public function system_prompt_instructions_callback() {
7967 // Retrieve the current value of the system prompt instructions
7968 $instructions = isset($this->options['system_prompt_instructions']) ? esc_textarea($this->options['system_prompt_instructions']) : '';
7969 // Render the textarea field
7970 printf(
7971 '<textarea id="system_prompt_instructions" name="system_prompt_instructions" rows="5" cols="50">%s</textarea>',
7972 $instructions
7973 );
7974 // Personalization hint
7975 echo '<p class="description" style="margin-top: 8px;">';
7976 echo esc_html__('Use {visitor_name} to personalize AI responses when lead capture is enabled.', 'mxchat') . '<br>';
7977 echo '<code style="font-size: 12px;">' . esc_html__('Example: The visitor\'s name is {visitor_name}. Address them by name.', 'mxchat') . '</code>';
7978 echo '</p>';
7979 // Sample instructions button
7980 echo '<div class="mxchat-instructions-container">';
7981 echo '<button type="button" class="mxchat-instructions-btn" id="mxchatViewSampleBtn">';
7982 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">';
7983 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
7984 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
7985 echo '</svg>';
7986 echo esc_html__('View Sample Instructions', 'mxchat');
7987 echo '</button>';
7988 echo '</div>';
7989
7990 // Add modal to WordPress admin footer instead of inline
7991 add_action('admin_footer', array($this, 'render_sample_instructions_modal'));
7992 }
7993
7994 // New method to render modal in admin footer
7995 public function render_sample_instructions_modal() {
7996 static $modal_rendered = false;
7997 if ($modal_rendered) return; // Prevent duplicate modals
7998 $modal_rendered = true;
7999
8000 echo '<div class="mxchat-instructions-modal-overlay" id="mxchatSampleModal">';
8001 echo '<div class="mxchat-instructions-modal-content">';
8002 echo '<div class="mxchat-instructions-modal-header">';
8003 echo '<h3 class="mxchat-instructions-modal-title">';
8004 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">';
8005 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
8006 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
8007 echo '</svg>';
8008 echo esc_html__('Sample AI Instructions', 'mxchat');
8009 echo '</h3>';
8010 echo '<button type="button" class="mxchat-instructions-modal-close" id="mxchatModalClose">';
8011 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">';
8012 echo '<line x1="18" y1="6" x2="6" y2="18"/>';
8013 echo '<line x1="6" y1="6" x2="18" y2="18"/>';
8014 echo '</svg>';
8015 echo '</button>';
8016 echo '</div>';
8017 echo '<div class="mxchat-instructions-modal-body">';
8018 echo '<div class="mxchat-instructions-content">';
8019 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:
8020
8021 # Response Style - CRITICALLY IMPORTANT
8022 - MAXIMUM LENGTH: 1-3 short sentences per response
8023 - Ultra-concise: Get straight to the answer with no filler
8024 - No introductions like "Sure!" or "I\'d be happy to help"
8025 - No phrases like "based on my knowledge" or "according to information"
8026 - No explanatory text before giving the answer
8027 - No summaries or repetition
8028 - Hyperlink all URLs
8029 - Respond in user\'s language
8030 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
8031
8032 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
8033 - ONLY answer using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
8034 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
8035 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
8036 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
8037 - Better to admit insufficient information than provide inaccurate answers');
8038 echo '</div>';
8039 echo '<button type="button" class="mxchat-instructions-copy-btn" id="mxchatCopyBtn">';
8040 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">';
8041 echo '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>';
8042 echo '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>';
8043 echo '</svg>';
8044 echo esc_html__('Copy Instructions', 'mxchat');
8045 echo '</button>';
8046 echo '</div>';
8047 echo '<div class="mxchat-instructions-modal-footer">';
8048 echo '<button type="button" class="mxchat-instructions-btn-secondary" id="mxchatCloseBtn">' . esc_html__('Close', 'mxchat') . '</button>';
8049 echo '</div>';
8050 echo '</div>';
8051 echo '</div>';
8052 }
8053
8054
8055 public function mxchat_model_callback() {
8056 // Catalog refactor (plan-d14e89): single source of truth lives in
8057 // includes/class-mxchat-model-catalog.php. Dropdown groups are the
8058 // provider labels; each group maps model_id => "Label" strings.
8059 if (!class_exists('MxChat_Model_Catalog')) {
8060 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
8061 }
8062 // Retrieve the currently selected model from saved options
8063 $selected_model = isset($this->options['model']) ? esc_attr($this->options['model']) : 'gpt-5.6-sol';
8064
8065 // Pass the saved model so a provider-retired id keeps rendering as the
8066 // current selection instead of a blank select (plan e46b8f).
8067 $models = MxChat_Model_Catalog::settings_dropdown_groups($selected_model);
8068
8069 // Begin the select dropdown
8070 echo '<select id="model" name="model">';
8071
8072 // Iterate over groups of models
8073 foreach ($models as $group_label => $group_models) {
8074 echo '<optgroup label="' . esc_attr($group_label) . '">';
8075
8076 foreach ($group_models as $model_value => $model_label) {
8077 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
8078 }
8079
8080 echo '</optgroup>';
8081 }
8082
8083 echo '</select>';
8084
8085 // Add a note for OpenRouter
8086 echo '<p class="description" id="openrouter-model-note" style="display:none; color: #d63638; font-weight: 500;">';
8087 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle;"></span> ';
8088 echo esc_html__('After entering your OpenRouter API key above, click the button below to load available models.', 'mxchat');
8089 echo '</p>';
8090
8091 // API Key Status Messages (hidden by default, shown by JS based on selected model)
8092 $has_openai_key = !empty($this->options['api_key']);
8093 $has_claude_key = !empty($this->options['claude_api_key']);
8094 $has_xai_key = !empty($this->options['xai_api_key']);
8095 $has_deepseek_key = !empty($this->options['deepseek_api_key']);
8096 $has_gemini_key = !empty($this->options['gemini_api_key']);
8097 $has_openrouter_key = !empty($this->options['openrouter_api_key']);
8098
8099 // OpenAI/GPT models
8100 echo '<p class="mxchat-api-status" data-provider="openai" style="display:none;">';
8101 if ($has_openai_key) {
8102 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
8103 } else {
8104 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8105 }
8106 echo '</p>';
8107
8108 // Claude models
8109 echo '<p class="mxchat-api-status" data-provider="claude" style="display:none;">';
8110 if ($has_claude_key) {
8111 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Anthropic (Claude) detected', 'mxchat') . '</span>';
8112 } else {
8113 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Anthropic (Claude) detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8114 }
8115 echo '</p>';
8116
8117 // X.AI models
8118 echo '<p class="mxchat-api-status" data-provider="xai" style="display:none;">';
8119 if ($has_xai_key) {
8120 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for X.AI (Grok) detected', 'mxchat') . '</span>';
8121 } else {
8122 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>';
8123 }
8124 echo '</p>';
8125
8126 // DeepSeek models
8127 echo '<p class="mxchat-api-status" data-provider="deepseek" style="display:none;">';
8128 if ($has_deepseek_key) {
8129 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for DeepSeek detected', 'mxchat') . '</span>';
8130 } else {
8131 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for DeepSeek detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8132 }
8133 echo '</p>';
8134
8135 // Gemini models
8136 echo '<p class="mxchat-api-status" data-provider="gemini" style="display:none;">';
8137 if ($has_gemini_key) {
8138 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8139 } else {
8140 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8141 }
8142 echo '</p>';
8143
8144 // OpenRouter models
8145 echo '<p class="mxchat-api-status" data-provider="openrouter" style="display:none;">';
8146 if ($has_openrouter_key) {
8147 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenRouter detected', 'mxchat') . '</span>';
8148 } else {
8149 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenRouter detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8150 }
8151 echo '</p>';
8152
8153 // ADD THESE HIDDEN FIELDS RIGHT HERE:
8154 $openrouter_model = isset($this->options['openrouter_selected_model']) ? esc_attr($this->options['openrouter_selected_model']) : '';
8155 $openrouter_model_name = isset($this->options['openrouter_selected_model_name']) ? esc_attr($this->options['openrouter_selected_model_name']) : '';
8156
8157 echo '<input type="hidden" id="openrouter_selected_model" name="openrouter_selected_model" value="' . $openrouter_model . '" />';
8158 echo '<input type="hidden" id="openrouter_selected_model_name" name="openrouter_selected_model_name" value="' . $openrouter_model_name . '" />';
8159 }
8160
8161 // Update your existing callback method
8162 public function enable_streaming_toggle_callback() {
8163 // Get value from options array, default to 'on'
8164 $enabled = isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on';
8165 $checked = ($enabled === 'on') ? 'checked' : '';
8166
8167 echo '<label class="toggle-switch">';
8168 echo sprintf(
8169 '<input type="checkbox" id="enable_streaming_toggle" name="enable_streaming_toggle" value="on" %s />',
8170 esc_attr($checked)
8171 );
8172 echo '<span class="slider"></span>';
8173 echo '</label>';
8174
8175 // Test button — branded .mxch-btn with inline SVG (IDs preserved for AJAX binding)
8176 echo '<div class="mxch-streaming-test-row">';
8177 echo '<button type="button" id="mxchat-test-streaming-btn" class="mxch-btn mxch-btn-secondary">';
8178 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>';
8179 echo esc_html__('Test Streaming Compatibility', 'mxchat');
8180 echo '</button>';
8181 echo '<p id="mxchat-test-streaming-result" class="mxch-streaming-test-result"></p>';
8182 echo '</div>';
8183 }
8184
8185 // Web Search toggle callback
8186 public function enable_web_search_toggle_callback() {
8187 // Get value from options array, default to 'off'
8188 $enabled = isset($this->options['enable_web_search']) ? $this->options['enable_web_search'] : 'off';
8189 $checked = ($enabled === 'on') ? 'checked' : '';
8190
8191 // Get current model to determine if we should show/enable the toggle
8192 $current_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
8193
8194 // Models that DON'T support web search — OpenAI-docs-driven exception list.
8195 // Keep hardcoded; the catalog can't infer "supports web search" per-model, so any
8196 // future OpenAI model that lacks Responses-API web_search support is added here.
8197 $unsupported_models = array('gpt-4.1-nano');
8198
8199 // Web-search-capable chat-model allowlists, derived from the central model catalog
8200 // (class-mxchat-model-catalog.php). When a new OpenAI/Gemini chat model is added there,
8201 // the Web Search toggle picks it up automatically — no edit here.
8202 // OpenAI grounds via the Responses-API web_search tool; Gemini grounds natively via the
8203 // Google Search tool (plan 46b9ea wired the Gemini dispatch — every shipped Gemini chat
8204 // model is 2.x/3.x and grounds, matching that path's empty opt-out list, so all catalog
8205 // Gemini models are supported here).
8206 if (!class_exists('MxChat_Model_Catalog')) {
8207 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
8208 }
8209 $chat_catalog = MxChat_Model_Catalog::chat_models();
8210 $openai_models = (isset($chat_catalog['openai']['models']) && is_array($chat_catalog['openai']['models']))
8211 ? array_keys($chat_catalog['openai']['models'])
8212 : array();
8213 $gemini_models = (isset($chat_catalog['gemini']['models']) && is_array($chat_catalog['gemini']['models']))
8214 ? array_keys($chat_catalog['gemini']['models'])
8215 : array();
8216
8217 $is_capable = in_array($current_model, $openai_models) || in_array($current_model, $gemini_models);
8218 $is_supported = $is_capable && !in_array($current_model, $unsupported_models);
8219
8220 // Wrapper div with data attributes for JS to show/hide. data-openai-models is kept for
8221 // back-compat; data-gemini-models is the added second provider the JS now also honors.
8222 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;"' : '') . '>';
8223
8224 echo '<label class="toggle-switch">';
8225 echo sprintf(
8226 '<input type="checkbox" id="enable_web_search" name="enable_web_search" value="on" %s />',
8227 esc_attr($checked)
8228 );
8229 echo '<span class="slider"></span>';
8230 echo '</label>';
8231
8232 echo '</div>';
8233
8234 // Message shown when a model that can't ground (Claude, Grok, DeepSeek, OpenRouter, etc.) is selected
8235 echo '<p id="web-search-unavailable-message" class="description" style="color: #666;' . ($is_supported ? ' display:none;' : '') . '">';
8236 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle; margin-right: 4px;"></span>';
8237 echo esc_html__('Web search is only available for OpenAI and Gemini models.', 'mxchat');
8238 echo '</p>';
8239 }
8240
8241 // AJAX handler to fetch OpenRouter models
8242 public function fetch_openrouter_models() {
8243 check_ajax_referer('mxchat_fetch_openrouter_models', 'nonce');
8244
8245 // plan-mxchat-20260731-c63fb6 — nonce is not authorization.
8246 if (!current_user_can('manage_options')) {
8247 wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat')), 403);
8248 }
8249
8250 $api_key = isset($_POST['api_key']) ? sanitize_text_field($_POST['api_key']) : '';
8251
8252 if (empty($api_key)) {
8253 wp_send_json_error(array('message' => 'API key is required'));
8254 }
8255
8256 $response = wp_remote_get('https://openrouter.ai/api/v1/models', array(
8257 'headers' => array(
8258 'Authorization' => 'Bearer ' . $api_key,
8259 'Content-Type' => 'application/json',
8260 ),
8261 'timeout' => 15,
8262 ));
8263
8264 if (is_wp_error($response)) {
8265 wp_send_json_error(array('message' => $response->get_error_message()));
8266 }
8267
8268 $body = wp_remote_retrieve_body($response);
8269 $data = json_decode($body, true);
8270
8271 if (isset($data['data']) && is_array($data['data'])) {
8272 // Format the models for the frontend
8273 $models = array_map(function($model) {
8274 return array(
8275 'id' => $model['id'],
8276 'name' => $model['name'] ?? $model['id'],
8277 'description' => $model['description'] ?? '',
8278 'context_length' => $model['context_length'] ?? 0,
8279 'pricing' => array(
8280 'prompt' => $model['pricing']['prompt'] ?? 0,
8281 'completion' => $model['pricing']['completion'] ?? 0,
8282 ),
8283 );
8284 }, $data['data']);
8285
8286 wp_send_json_success(array('models' => $models));
8287 } else {
8288 wp_send_json_error(array('message' => 'Invalid response from OpenRouter'));
8289 }
8290 }
8291
8292 // Callback function for embedding model selection
8293 public function embedding_model_callback() {
8294 $models = array(
8295 esc_html__('OpenAI Embeddings', 'mxchat') => array(
8296 'text-embedding-3-small' => esc_html__('TE3 Small (1536, Efficient)', 'mxchat'),
8297 'text-embedding-ada-002' => esc_html__('Ada 2 (1536, Recommended)', 'mxchat'),
8298 'text-embedding-3-large' => esc_html__('TE3 Large (3072, Powerful)', 'mxchat'),
8299 ),
8300 esc_html__('Voyage AI Embeddings', 'mxchat') => array(
8301 'voyage-3-large' => esc_html__('Voyage-3 Large (2048, Most Capable)', 'mxchat'),
8302 ),
8303 esc_html__('Google Gemini Embeddings', 'mxchat') => array(
8304 'gemini-embedding-001' => esc_html__('Gemini Embedding (1536, Stable)', 'mxchat'),
8305 )
8306 );
8307 $selected_model = isset($this->options['embedding_model']) ? esc_attr($this->options['embedding_model']) : 'text-embedding-ada-002';
8308 // With custom-provider embeddings on, this picker is inert — the effective
8309 // model comes from the Custom Embedding Model field (plan ae02cb). Autosave
8310 // fires only on user change events, so the disabled attribute cannot cause
8311 // a missing-key save; JS keeps the state in sync with the toggle live.
8312 $custom_embeddings_on = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
8313 echo '<select id="embedding_model" name="embedding_model"' . ($custom_embeddings_on ? ' disabled' : '') . '>';
8314 foreach ($models as $group_label => $group_models) {
8315 echo '<optgroup label="' . esc_attr($group_label) . '">';
8316 foreach ($group_models as $model_value => $model_label) {
8317 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
8318 }
8319 echo '</optgroup>';
8320 }
8321 echo '</select>';
8322 echo '<p class="mxch-field-description mxchat-embedding-custom-note" id="mxchat_embedding_custom_note"' . ($custom_embeddings_on ? '' : ' style="display:none;"') . '>';
8323 echo esc_html__('Custom provider embeddings are in use — the model is set in the Custom Embedding Model field under Custom Provider.', 'mxchat');
8324 echo '</p>';
8325
8326 // API Key Status Messages for Embedding Models
8327 $has_openai_key = !empty($this->options['api_key']);
8328 $has_voyage_key = !empty($this->options['voyage_api_key']);
8329 $has_gemini_key = !empty($this->options['gemini_api_key']);
8330
8331 // OpenAI Embeddings
8332 echo '<p class="mxchat-embedding-api-status" data-provider="openai" style="display:none;">';
8333 if ($has_openai_key) {
8334 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
8335 } else {
8336 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8337 }
8338 echo '</p>';
8339
8340 // Voyage AI Embeddings
8341 echo '<p class="mxchat-embedding-api-status" data-provider="voyage" style="display:none;">';
8342 if ($has_voyage_key) {
8343 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Voyage AI detected', 'mxchat') . '</span>';
8344 } else {
8345 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Voyage AI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8346 }
8347 echo '</p>';
8348
8349 // Gemini Embeddings
8350 echo '<p class="mxchat-embedding-api-status" data-provider="gemini" style="display:none;">';
8351 if ($has_gemini_key) {
8352 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8353 } else {
8354 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8355 }
8356 echo '</p>';
8357
8358 }
8359
8360
8361 public function mxchat_top_bar_title_callback() {
8362 // Retrieve the current value of the top bar title from saved options
8363 $top_bar_title = isset($this->options['top_bar_title']) ? esc_attr($this->options['top_bar_title']) : '';
8364
8365 // Render the input field
8366 echo '<input type="text" id="top_bar_title" name="top_bar_title" value="' . $top_bar_title . '" />';
8367 }
8368 public function mxchat_ai_agent_text_callback() {
8369 // Retrieve the current value of the AI agent text from saved options
8370 $ai_agent_text = isset($this->options['ai_agent_text']) ? esc_attr($this->options['ai_agent_text']) : '';
8371 // Render the input field
8372 echo '<input type="text" id="ai_agent_text" name="ai_agent_text" value="' . $ai_agent_text . '" />';
8373 }
8374
8375
8376 public function enable_email_block_callback() {
8377 // Load full plugin options array
8378 $all_options = get_option('mxchat_options', []);
8379
8380 // Get the value, default to 'off'
8381 $enable_email_block = isset($all_options['enable_email_block']) ? $all_options['enable_email_block'] : 'off';
8382
8383 // Check if it's 'on'
8384 $checked = ($enable_email_block === 'on') ? 'checked' : '';
8385
8386 echo '<label class="toggle-switch">';
8387 echo sprintf(
8388 '<input type="checkbox" id="enable_email_block" name="enable_email_block" value="on" %s />',
8389 esc_attr($checked)
8390 );
8391 echo '<span class="slider"></span>';
8392 echo '</label>';
8393 }
8394
8395 public function email_blocker_header_content_callback() {
8396 // Load the entire 'mxchat_options' array
8397 $all_options = get_option('mxchat_options', []);
8398
8399 // Retrieve the saved content or default to empty
8400 $content = isset($all_options['email_blocker_header_content'])
8401 ? $all_options['email_blocker_header_content']
8402 : '';
8403
8404 // Render the textarea - IMPORTANT: name should be just "email_blocker_header_content"
8405 echo '<textarea
8406 id="email_blocker_header_content"
8407 name="email_blocker_header_content"
8408 rows="5"
8409 cols="70"
8410 data-setting="email_blocker_header_content"
8411 >' . esc_textarea($content) . '</textarea>';
8412 }
8413
8414 public function email_blocker_button_text_callback() {
8415 // Load the entire 'mxchat_options' array
8416 $all_options = get_option('mxchat_options', []);
8417
8418 // Retrieve the saved button text or default to empty
8419 $button_text = isset($all_options['email_blocker_button_text'])
8420 ? $all_options['email_blocker_button_text']
8421 : '';
8422
8423 // Use esc_attr to safely render the existing text
8424 echo '<input type="text" id="email_blocker_button_text" name="email_blocker_button_text" value="' . esc_attr($button_text) . '" style="width: 300px;" />';
8425 }
8426
8427 //Enable name field callback
8428 public function enable_name_field_callback() {
8429 // Load full plugin options array
8430 $all_options = get_option('mxchat_options', []);
8431 // Get the value, default to 'off'
8432 $enable_name_field = isset($all_options['enable_name_field']) ? $all_options['enable_name_field'] : 'off';
8433 // Check if it's 'on'
8434 $checked = ($enable_name_field === 'on') ? 'checked' : '';
8435 echo '<label class="toggle-switch">';
8436 echo sprintf(
8437 '<input type="checkbox" id="enable_name_field" name="enable_name_field" value="on" %s />',
8438 esc_attr($checked)
8439 );
8440 echo '<span class="slider"></span>';
8441 echo '</label>';
8442 }
8443 //Name field placeholder callback
8444 public function name_field_placeholder_callback() {
8445 $all_options = get_option('mxchat_options', []);
8446 $placeholder = isset($all_options['name_field_placeholder'])
8447 ? $all_options['name_field_placeholder']
8448 : esc_html__('Enter your name', 'mxchat');
8449
8450 echo '<input type="text" id="name_field_placeholder" name="name_field_placeholder" value="' . esc_attr($placeholder) . '" style="width: 300px;" />';
8451 }
8452
8453 // Consent checkbox toggle (b062c4)
8454 public function enable_consent_checkbox_callback() {
8455 $all_options = get_option('mxchat_options', []);
8456 $enabled = isset($all_options['enable_consent_checkbox']) ? $all_options['enable_consent_checkbox'] : 'off';
8457 $checked = ($enabled === 'on') ? 'checked' : '';
8458 echo '<label class="toggle-switch">';
8459 echo sprintf(
8460 '<input type="checkbox" id="enable_consent_checkbox" name="enable_consent_checkbox" value="on" %s />',
8461 esc_attr($checked)
8462 );
8463 echo '<span class="slider"></span>';
8464 echo '</label>';
8465 }
8466
8467 // Consent checkbox label (b062c4). Owner content; a safe HTML subset is
8468 // allowed (anchor + inline emphasis) so the Privacy Policy can be linked.
8469 public function consent_checkbox_label_callback() {
8470 $all_options = get_option('mxchat_options', []);
8471 $label = isset($all_options['consent_checkbox_label'])
8472 ? $all_options['consent_checkbox_label']
8473 : __('I agree to the Privacy Policy.', 'mxchat');
8474
8475 echo '<textarea
8476 id="consent_checkbox_label"
8477 name="consent_checkbox_label"
8478 rows="2"
8479 cols="70"
8480 data-setting="consent_checkbox_label"
8481 >' . esc_textarea($label) . '</textarea>';
8482 }
8483
8484 // Consent required toggle (b062c4)
8485 public function consent_checkbox_required_callback() {
8486 $all_options = get_option('mxchat_options', []);
8487 $required = isset($all_options['consent_checkbox_required']) ? $all_options['consent_checkbox_required'] : 'off';
8488 $checked = ($required === 'on') ? 'checked' : '';
8489 echo '<label class="toggle-switch">';
8490 echo sprintf(
8491 '<input type="checkbox" id="consent_checkbox_required" name="consent_checkbox_required" value="on" %s />',
8492 esc_attr($checked)
8493 );
8494 echo '<span class="slider"></span>';
8495 echo '</label>';
8496 }
8497
8498
8499
8500 public function mxchat_intro_message_callback() {
8501 // Load the entire 'mxchat_options' array
8502 $all_options = get_option('mxchat_options', []);
8503 // Retrieve the saved intro message or use the default
8504 $default_message = __('Hello! How can I assist you today?', 'mxchat');
8505 $saved_message = isset($all_options['intro_message']) ? $all_options['intro_message'] : $default_message;
8506 // Escape on output (esc_textarea) — neutralizes any payload already stored before the
8507 // Wordfence Stored-XSS fix (CWE-79, plan-3f8158) and prevents </textarea> context-breakout.
8508 ?>
8509 <textarea id="intro_message" name="intro_message" rows="5" cols="50"><?php echo esc_textarea( $saved_message ); ?></textarea>
8510 <p class="description" style="margin-top: 8px;">
8511 <?php esc_html_e('Use {visitor_name} to personalize greetings when lead capture is enabled.', 'mxchat'); ?><br>
8512 <code style="font-size: 12px;"><?php esc_html_e('Example: Hello {visitor_name}! How can I help you today?', 'mxchat'); ?></code>
8513 </p>
8514 <?php
8515 }
8516
8517 public function mxchat_input_copy_callback() {
8518 // Load the entire 'mxchat_options' array
8519 $all_options = get_option('mxchat_options', []);
8520
8521 // Retrieve the saved input copy or use the default value
8522 $default_copy = __('How can I assist?', 'mxchat');
8523 $input_copy = isset($all_options['input_copy']) ? $all_options['input_copy'] : $default_copy;
8524
8525 // Output the input field with the saved value
8526 printf(
8527 '<input type="text" id="input_copy" name="input_copy" value="%s" placeholder="%s" />',
8528 esc_attr($input_copy),
8529 esc_attr__('How can I assist?', 'mxchat')
8530 );
8531 }
8532
8533
8534 public function mxchat_append_to_body_callback() {
8535 // Fetch fresh options to ensure we have the latest saved values
8536 $options = get_option('mxchat_options', array());
8537
8538 // Get value from options array, default to 'off'
8539 $append_to_body = isset($options['append_to_body']) ? $options['append_to_body'] : 'off';
8540 $checked = ($append_to_body === 'on') ? 'checked' : '';
8541
8542 // Get post type visibility settings
8543 $visibility_mode = isset($options['post_type_visibility_mode']) ? $options['post_type_visibility_mode'] : 'all';
8544 $visibility_list = isset($options['post_type_visibility_list']) ? $options['post_type_visibility_list'] : array();
8545 if (!is_array($visibility_list)) {
8546 $visibility_list = array();
8547 }
8548
8549 echo '<div class="mxchat-autosave-section">';
8550
8551 // Main toggle
8552 echo '<label class="toggle-switch">';
8553 echo sprintf(
8554 '<input type="checkbox" id="append_to_body" name="append_to_body" value="on" %s />',
8555 esc_attr($checked)
8556 );
8557 echo '<span class="slider"></span>';
8558 echo '</label>';
8559
8560 // Post Type Visibility Options (only visible when auto-display is ON)
8561 $display_style = ($append_to_body === 'on') ? '' : 'display: none;';
8562 echo '<div id="post-type-visibility-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8563
8564 echo '<div class="mxchat-post-type-visibility-header">';
8565 echo '<h4>' . esc_html__('Post Type Visibility', 'mxchat') . '</h4>';
8566 echo '</div>';
8567
8568 // Mode selector (radio buttons)
8569 echo '<div class="mxchat-visibility-mode">';
8570
8571 echo '<label class="mxchat-radio-label">';
8572 echo '<input type="radio" name="post_type_visibility_mode" value="all" ' . checked($visibility_mode, 'all', false) . ' />';
8573 echo '<span>' . esc_html__('Show on all post types', 'mxchat') . '</span>';
8574 echo '</label>';
8575
8576 echo '<label class="mxchat-radio-label">';
8577 echo '<input type="radio" name="post_type_visibility_mode" value="include" ' . checked($visibility_mode, 'include', false) . ' />';
8578 echo '<span>' . esc_html__('Only show on selected post types', 'mxchat') . '</span>';
8579 echo '</label>';
8580
8581 echo '<label class="mxchat-radio-label">';
8582 echo '<input type="radio" name="post_type_visibility_mode" value="exclude" ' . checked($visibility_mode, 'exclude', false) . ' />';
8583 echo '<span>' . esc_html__('Hide on selected post types', 'mxchat') . '</span>';
8584 echo '</label>';
8585
8586 echo '</div>';
8587
8588 // Post type checkboxes (only visible when mode is include or exclude)
8589 $list_display = ($visibility_mode !== 'all') ? '' : 'display: none;';
8590 echo '<div id="post-type-list" class="mxchat-post-type-list" style="' . esc_attr($list_display) . '">';
8591
8592 // Get all public post types
8593 $post_types = get_post_types(array('public' => true), 'objects');
8594
8595 foreach ($post_types as $post_type) {
8596 // Skip attachments
8597 if ($post_type->name === 'attachment') {
8598 continue;
8599 }
8600
8601 $is_checked = in_array($post_type->name, $visibility_list) ? 'checked' : '';
8602
8603 echo '<label class="mxchat-checkbox-label">';
8604 echo '<input type="checkbox" name="post_type_visibility_list[]" value="' . esc_attr($post_type->name) . '" ' . $is_checked . ' />';
8605 echo '<span>' . esc_html($post_type->label) . '</span>';
8606 echo '</label>';
8607 }
8608
8609 echo '</div>'; // End post-type-list
8610 echo '</div>'; // End post-type-visibility-options
8611 echo '</div>'; // End mxchat-autosave-section
8612 }
8613
8614 public function mxchat_contextual_awareness_callback() {
8615 // Get value from options array, default to 'off'
8616 $contextual_awareness = isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off';
8617 $checked = ($contextual_awareness === 'on') ? 'checked' : '';
8618 echo '<label class="toggle-switch">';
8619 echo sprintf(
8620 '<input type="checkbox" id="contextual_awareness_toggle" name="contextual_awareness_toggle" value="on" %s />',
8621 esc_attr($checked)
8622 );
8623 echo '<span class="slider"></span>';
8624 echo '</label>';
8625 }
8626
8627 public function mxchat_citation_links_toggle_callback() {
8628 // Get value from options array, default to 'on' (enabled by default)
8629 $citation_links = isset($this->options['citation_links_toggle']) ? $this->options['citation_links_toggle'] : 'on';
8630 $checked = ($citation_links === 'on') ? 'checked' : '';
8631 echo '<label class="toggle-switch">';
8632 echo sprintf(
8633 '<input type="checkbox" id="citation_links_toggle" name="citation_links_toggle" value="on" %s />',
8634 esc_attr($checked)
8635 );
8636 echo '<span class="slider"></span>';
8637 echo '</label>';
8638 }
8639
8640 /**
8641 * "Strip unapproved links" toggle (plan 58f8b4). Until the site saves an
8642 * explicit value the rendered state is the install-stamp default — on for
8643 * installs born at 3.2.20+, off for upgrades — so what the admin sees here
8644 * always matches what the response URL guard is actually doing.
8645 */
8646 public function mxchat_strip_unapproved_links_toggle_callback() {
8647 if (isset($this->options['strip_unapproved_links_toggle'])) {
8648 $strip_links = $this->options['strip_unapproved_links_toggle'];
8649 } else {
8650 $strip_links = function_exists('mxchat_strip_unapproved_links_default')
8651 ? mxchat_strip_unapproved_links_default()
8652 : 'off';
8653 }
8654 $checked = ($strip_links === 'on') ? 'checked' : '';
8655 echo '<label class="toggle-switch">';
8656 echo sprintf(
8657 '<input type="checkbox" id="strip_unapproved_links_toggle" name="strip_unapproved_links_toggle" value="on" %s />',
8658 esc_attr($checked)
8659 );
8660 echo '<span class="slider"></span>';
8661 echo '</label>';
8662 }
8663
8664 /**
8665 * Toggle for the end-of-session satisfaction rating prompt (plan-a5b006).
8666 * Default ON. The widget reads this through the localized object; the
8667 * mxchat_satisfaction_rating_enabled filter still lets developers force
8668 * the value site-wide.
8669 *
8670 * The 5 customization fields (idle/question/thanks/placeholder/saved) are
8671 * rendered inline here inside a single wrapper div whose initial display
8672 * is set server-side from the toggle value (plan-29caac). Mirrors the
8673 * auto-display chatbot pattern at mxchat_append_to_body_callback — no
8674 * DOMContentLoaded race because rows exist as direct children of this
8675 * callback's output, and the wrapper's display: none is inline at render
8676 * time so refresh shows the correct state with no flash.
8677 */
8678 public function mxchat_satisfaction_rating_toggle_callback() {
8679 $options = $this->options;
8680 $value = isset($options['satisfaction_rating_enabled']) ? $options['satisfaction_rating_enabled'] : 'off';
8681 $checked = ($value === 'on') ? 'checked' : '';
8682
8683 $idle = isset($options['satisfaction_rating_idle_seconds']) ? intval($options['satisfaction_rating_idle_seconds']) : 60;
8684 $idle = max(5, min(600, $idle));
8685 $question = isset($options['satisfaction_rating_question']) ? $options['satisfaction_rating_question'] : '';
8686 $thanks = isset($options['satisfaction_rating_thanks']) ? $options['satisfaction_rating_thanks'] : '';
8687 $placeholder = isset($options['satisfaction_rating_placeholder']) ? $options['satisfaction_rating_placeholder'] : '';
8688 $saved = isset($options['satisfaction_rating_saved']) ? $options['satisfaction_rating_saved'] : '';
8689
8690 echo '<div class="mxchat-autosave-section">';
8691
8692 echo '<label class="toggle-switch">';
8693 echo sprintf(
8694 '<input type="checkbox" id="satisfaction_rating_enabled" name="satisfaction_rating_enabled" value="on" %s />',
8695 esc_attr($checked)
8696 );
8697 echo '<span class="slider"></span>';
8698 echo '</label>';
8699
8700 ?>
8701 <style>
8702 .mxchat-sub-options-field { margin: 12px 0; }
8703 .mxchat-sub-options-field label { display: inline-block; margin-bottom: 4px; }
8704 #satisfaction-rating-sub-options h4 { margin: 16px 0 8px; }
8705 </style>
8706 <?php
8707
8708 $display_style = ($value === 'on') ? '' : 'display: none;';
8709 echo '<div id="satisfaction-rating-sub-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8710
8711 echo '<h4>' . esc_html__('Customize the prompt (optional)', 'mxchat') . '</h4>';
8712
8713 echo '<div class="mxchat-sub-options-field">';
8714 echo '<label for="satisfaction_rating_idle_seconds"><strong>' . esc_html__('Idle Timeout', 'mxchat') . '</strong></label><br />';
8715 printf(
8716 '<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>',
8717 (int) $idle,
8718 esc_html__('seconds of user inactivity before the prompt appears (5-600)', 'mxchat')
8719 );
8720 echo '</div>';
8721
8722 echo '<div class="mxchat-sub-options-field">';
8723 echo '<label for="satisfaction_rating_question"><strong>' . esc_html__('Prompt Question', 'mxchat') . '</strong></label><br />';
8724 printf(
8725 '<input type="text" id="satisfaction_rating_question" name="satisfaction_rating_question" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8726 esc_attr($question),
8727 esc_attr__('Was this helpful?', 'mxchat')
8728 );
8729 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown above the thumbs up/down.', 'mxchat') . '</p>';
8730 echo '</div>';
8731
8732 echo '<div class="mxchat-sub-options-field">';
8733 echo '<label for="satisfaction_rating_thanks"><strong>' . esc_html__('Thank-You Message', 'mxchat') . '</strong></label><br />';
8734 printf(
8735 '<input type="text" id="satisfaction_rating_thanks" name="satisfaction_rating_thanks" value="%s" maxlength="300" class="regular-text" placeholder="%s" />',
8736 esc_attr($thanks),
8737 esc_attr__('Thanks! Anything we should improve? (optional)', 'mxchat')
8738 );
8739 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the user clicks a thumb.', 'mxchat') . '</p>';
8740 echo '</div>';
8741
8742 echo '<div class="mxchat-sub-options-field">';
8743 echo '<label for="satisfaction_rating_placeholder"><strong>' . esc_html__('Feedback Placeholder', 'mxchat') . '</strong></label><br />';
8744 printf(
8745 '<input type="text" id="satisfaction_rating_placeholder" name="satisfaction_rating_placeholder" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8746 esc_attr($placeholder),
8747 esc_attr__('Tell us what could be better…', 'mxchat')
8748 );
8749 echo '<p class="description">' . esc_html__('Leave blank for the default. Placeholder text inside the feedback textarea.', 'mxchat') . '</p>';
8750 echo '</div>';
8751
8752 echo '<div class="mxchat-sub-options-field">';
8753 echo '<label for="satisfaction_rating_saved"><strong>' . esc_html__('Saved Confirmation', 'mxchat') . '</strong></label><br />';
8754 printf(
8755 '<input type="text" id="satisfaction_rating_saved" name="satisfaction_rating_saved" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
8756 esc_attr($saved),
8757 esc_attr__('Thanks for the feedback.', 'mxchat')
8758 );
8759 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the feedback is sent.', 'mxchat') . '</p>';
8760 echo '</div>';
8761
8762 echo '</div>'; // #satisfaction-rating-sub-options
8763 echo '</div>'; // .mxchat-autosave-section
8764
8765 ?>
8766 <script>
8767 (function() {
8768 document.addEventListener('DOMContentLoaded', function() {
8769 var toggle = document.getElementById('satisfaction_rating_enabled');
8770 var subOptions = document.getElementById('satisfaction-rating-sub-options');
8771 if (!toggle || !subOptions) return;
8772 toggle.addEventListener('change', function() {
8773 subOptions.style.display = toggle.checked ? '' : 'none';
8774 });
8775 });
8776 })();
8777 </script>
8778 <?php
8779 }
8780
8781 public function mxchat_privacy_toggle_callback() {
8782 // Load from mxchat_options array
8783 $options = get_option('mxchat_options', []);
8784
8785 // Get privacy toggle value with fallback
8786 $privacy_toggle = isset($options['privacy_toggle']) ? $options['privacy_toggle'] : 'off';
8787 $checked = ($privacy_toggle === 'on') ? 'checked' : '';
8788
8789 // Get privacy text with fallback
8790 $privacy_text = isset($options['privacy_text'])
8791 ? $options['privacy_text']
8792 : __('By chatting, you agree to our <a href="https://example.com/privacy-policy" target="_blank">privacy policy</a>.', 'mxchat');
8793
8794 // Output the toggle switch
8795 echo '<label class="toggle-switch">';
8796 echo sprintf(
8797 '<input type="checkbox" id="privacy_toggle" name="privacy_toggle" value="on" %s />',
8798 esc_attr($checked)
8799 );
8800 echo '<span class="slider"></span>';
8801 echo '</label>';
8802
8803 // Output the custom text input field
8804 echo sprintf(
8805 '<textarea id="privacy_text" name="privacy_text" rows="5" cols="50" class="regular-text">%s</textarea>',
8806 esc_textarea($privacy_text)
8807 );
8808 }
8809
8810
8811 public function mxchat_complianz_toggle_callback() {
8812 // Load from mxchat_options array
8813 $options = get_option('mxchat_options', []);
8814
8815 // Get complianz toggle value with fallback
8816 $complianz_toggle = isset($options['complianz_toggle']) ? $options['complianz_toggle'] : 'off';
8817 $checked = ($complianz_toggle === 'on') ? 'checked' : '';
8818
8819 // Output the toggle switch
8820 echo '<label class="toggle-switch">';
8821 echo sprintf(
8822 '<input type="checkbox" id="complianz_toggle" name="complianz_toggle" value="on" %s />',
8823 esc_attr($checked)
8824 );
8825 echo '<span class="slider"></span>';
8826 echo '</label>';
8827 }
8828
8829 public function mxchat_link_target_toggle_callback() {
8830 // Load from mxchat_options array
8831 $options = get_option('mxchat_options', []);
8832
8833 // Get link target toggle value with fallback
8834 $link_target_toggle = isset($options['link_target_toggle']) ? $options['link_target_toggle'] : 'off';
8835 $checked = ($link_target_toggle === 'on') ? 'checked' : '';
8836
8837 // Output the toggle switch
8838 echo '<label class="toggle-switch">';
8839 echo sprintf(
8840 '<input type="checkbox" id="link_target_toggle" name="link_target_toggle" value="on" %s />',
8841 esc_attr($checked)
8842 );
8843 echo '<span class="slider"></span>';
8844 echo '</label>';
8845 }
8846
8847 public function mxchat_chat_persistence_toggle_callback() {
8848 // Load from mxchat_options array
8849 $options = get_option('mxchat_options', []);
8850
8851 // Get chat persistence toggle value with fallback
8852 $chat_persistence_toggle = isset($options['chat_persistence_toggle']) ? $options['chat_persistence_toggle'] : 'off';
8853 $checked = ($chat_persistence_toggle === 'on') ? 'checked' : '';
8854
8855 // Output the toggle switch
8856 echo '<label class="toggle-switch">';
8857 echo sprintf(
8858 '<input type="checkbox" id="chat_persistence_toggle" name="chat_persistence_toggle" value="on" %s />',
8859 esc_attr($checked)
8860 );
8861 echo '<span class="slider"></span>';
8862 echo '</label>';
8863 }
8864
8865 public function mxchat_print_button_toggle_callback() {
8866 // Load from mxchat_options array
8867 $options = get_option('mxchat_options', []);
8868
8869 // Default ON — the option was previously unexposed and the button always showed.
8870 $print_button_enabled = isset($options['print_button_enabled']) ? $options['print_button_enabled'] : 'on';
8871 $checked = ($print_button_enabled === 'on') ? 'checked' : '';
8872
8873 // Output the toggle switch
8874 echo '<label class="toggle-switch">';
8875 echo sprintf(
8876 '<input type="checkbox" id="print_button_enabled" name="print_button_enabled" value="on" %s />',
8877 esc_attr($checked)
8878 );
8879 echo '<span class="slider"></span>';
8880 echo '</label>';
8881 }
8882
8883 /**
8884 * Editor Assistant toggle (plan-8cb0cb). Standalone option, NOT in mxchat_options
8885 * (bypasses the mxchat_sanitize strip-trap + autosave normalization). Default OFF.
8886 * Saved by the mxchat_editor_assistant_enabled case in class-ajax-handler.php.
8887 * Renders on Content → Settings (moved there from Settings → Behavior, plan-f7df40).
8888 */
8889 public function mxchat_editor_assistant_toggle_callback() {
8890 $enabled = get_option('mxchat_editor_assistant_enabled', 'off');
8891 $checked = ($enabled === 'on') ? 'checked' : '';
8892
8893 echo '<label class="toggle-switch">';
8894 echo sprintf(
8895 '<input type="checkbox" id="mxchat_editor_assistant_enabled" name="mxchat_editor_assistant_enabled" value="on" %s />',
8896 esc_attr($checked)
8897 );
8898 echo '<span class="slider"></span>';
8899 echo '</label>';
8900 }
8901
8902 /**
8903 * Hybrid keyword boost toggle (plan-38ffa1; rebuilt on the house toggle pattern
8904 * in plan-64d34f — the hand-rolled .mxch-toggle markup matched neither selector
8905 * the autosave JS uses to place its save confirmation, so the control looked
8906 * dead). Standalone option, NOT in mxchat_options. Default OFF. Saved by the
8907 * mxchat_hybrid_keyword_toggle case in class-ajax-handler.php, which also runs
8908 * capability detection on enable.
8909 */
8910 public function mxchat_hybrid_keyword_toggle_callback() {
8911 $enabled = get_option('mxchat_hybrid_keyword_toggle', 'off');
8912 $checked = ($enabled === 'on') ? 'checked' : '';
8913
8914 echo '<label class="toggle-switch">';
8915 echo sprintf(
8916 '<input type="checkbox" id="mxchat_hybrid_keyword_toggle" name="mxchat_hybrid_keyword_toggle" value="on" %s />',
8917 esc_attr($checked)
8918 );
8919 echo '<span class="slider"></span>';
8920 echo '</label>';
8921 }
8922
8923 /**
8924 * Smart asset loading toggle (plan-915355; rebuilt on the house toggle pattern
8925 * in plan-64d34f, same dead-save-feedback defect as the hybrid keyword toggle).
8926 * Standalone option, NOT in mxchat_options. Default OFF. Saved by the
8927 * mxchat_smart_asset_loading case in class-ajax-handler.php.
8928 */
8929 public function mxchat_smart_asset_loading_toggle_callback() {
8930 $enabled = get_option('mxchat_smart_asset_loading', 'off');
8931 $checked = ($enabled === 'on') ? 'checked' : '';
8932
8933 echo '<label class="toggle-switch">';
8934 echo sprintf(
8935 '<input type="checkbox" id="mxchat_smart_asset_loading" name="mxchat_smart_asset_loading" value="on" %s />',
8936 esc_attr($checked)
8937 );
8938 echo '<span class="slider"></span>';
8939 echo '</label>';
8940 }
8941
8942 public function mxchat_reset_chat_toggle_callback() {
8943 // Load from mxchat_options array. plan ac2e81 — default OFF (new, opt-in).
8944 $options = get_option('mxchat_options', []);
8945 $reset_chat_enabled = isset($options['reset_chat_enabled']) ? $options['reset_chat_enabled'] : 'off';
8946 $checked = ($reset_chat_enabled === 'on') ? 'checked' : '';
8947
8948 echo '<label class="toggle-switch">';
8949 echo sprintf(
8950 '<input type="checkbox" id="reset_chat_enabled" name="reset_chat_enabled" value="on" %s />',
8951 esc_attr($checked)
8952 );
8953 echo '<span class="slider"></span>';
8954 echo '</label>';
8955 }
8956
8957 public function mxchat_reset_chat_label_callback() {
8958 // Editable label for the "Start new chat" menu item. plan ac2e81.
8959 $options = get_option('mxchat_options', []);
8960 $reset_chat_label = isset($options['reset_chat_label']) ? $options['reset_chat_label'] : '';
8961
8962 printf(
8963 '<input type="text" id="reset_chat_label" name="reset_chat_label" value="%s" placeholder="%s" class="regular-text" />',
8964 esc_attr($reset_chat_label),
8965 esc_attr__('Start new chat', 'mxchat')
8966 );
8967 }
8968
8969 public function mxchat_popular_question_1_callback() {
8970 // Load the full plugin options array
8971 $all_options = get_option('mxchat_options', []);
8972
8973 // Retrieve the specific option for popular_question_1
8974 $popular_question_1 = isset($all_options['popular_question_1']) ? $all_options['popular_question_1'] : '';
8975
8976 // Render the input field
8977 printf(
8978 '<input type="text" id="popular_question_1" name="popular_question_1" value="%s" placeholder="%s" class="regular-text" />',
8979 esc_attr($popular_question_1),
8980 esc_attr__('Enter Quick Question 1', 'mxchat')
8981 );
8982 }
8983
8984
8985 public function mxchat_popular_question_2_callback() {
8986 // Load the full plugin options array
8987 $all_options = get_option('mxchat_options', []);
8988
8989 // Retrieve the specific option for popular_question_2
8990 $popular_question_2 = isset($all_options['popular_question_2']) ? $all_options['popular_question_2'] : '';
8991
8992 // Render the input field
8993 printf(
8994 '<input type="text" id="popular_question_2" name="popular_question_2" value="%s" placeholder="%s" class="regular-text" />',
8995 esc_attr($popular_question_2),
8996 esc_attr__('Enter Quick Question 2', 'mxchat')
8997 );
8998 }
8999
9000
9001 public function mxchat_popular_question_3_callback() {
9002 // Load the full plugin options array
9003 $all_options = get_option('mxchat_options', []);
9004
9005 // Retrieve the specific option for popular_question_3
9006 $popular_question_3 = isset($all_options['popular_question_3']) ? $all_options['popular_question_3'] : '';
9007
9008 // Render the input field
9009 printf(
9010 '<input type="text" id="popular_question_3" name="popular_question_3" value="%s" placeholder="%s" class="regular-text" />',
9011 esc_attr($popular_question_3),
9012 esc_attr(__('Enter Quick Question 3', 'mxchat'))
9013 );
9014 }
9015
9016 public function mxchat_additional_popular_questions_callback() {
9017 $options = get_option('mxchat_options', []);
9018 $additional_questions = isset($options['additional_popular_questions'])
9019 ? $options['additional_popular_questions']
9020 : get_option('additional_popular_questions', array());
9021
9022 echo '<div id="mxchat-additional-questions-container">';
9023 if (!empty($additional_questions)) {
9024 foreach ($additional_questions as $index => $question) {
9025 printf(
9026 '<div class="mxchat-question-row">
9027 <input type="text" name="additional_popular_questions[]"
9028 value="%s"
9029 placeholder="%s"
9030 class="regular-text mxchat-question-input"
9031 data-question-index="%d" />
9032 <button type="button" class="button mxchat-remove-question"
9033 aria-label="%s">%s</button>
9034 </div>',
9035 esc_attr($question),
9036 esc_attr(sprintf(__('Enter Additional Quick Question %d', 'mxchat'), $index + 4)),
9037 $index,
9038 esc_attr(__('Remove question', 'mxchat')),
9039 esc_html__('Remove', 'mxchat')
9040 );
9041 }
9042 } else {
9043 printf(
9044 '<div class="mxchat-question-row">
9045 <input type="text" name="additional_popular_questions[]"
9046 value=""
9047 placeholder="%s"
9048 class="regular-text mxchat-question-input"
9049 data-question-index="0" />
9050 <button type="button" class="button mxchat-remove-question"
9051 aria-label="%s">%s</button>
9052 </div>',
9053 esc_attr(__('Enter Additional Quick Question 4', 'mxchat')),
9054 esc_attr(__('Remove question', 'mxchat')),
9055 esc_html__('Remove', 'mxchat')
9056 );
9057 }
9058 echo '</div>';
9059 printf(
9060 '<button type="button" class="button mxchat-add-question" aria-label="%s">%s</button>',
9061 esc_attr(__('Add question', 'mxchat')),
9062 esc_html__('Add Question', 'mxchat')
9063 );
9064 }
9065
9066 public function mxchat_brave_api_key_callback() {
9067 $brave_api_key = isset($this->options['brave_api_key']) ? esc_attr($this->options['brave_api_key']) : '';
9068 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9069
9070 echo '<div class="api-key-wrapper">';
9071 echo sprintf(
9072 '<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" />',
9073 $brave_api_key,
9074 $nonce
9075 );
9076 echo '<button type="button" id="toggleBraveApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9077 echo '</div>';
9078 echo '<p class="description">' . __('Required for Brave Search integration. Get your API key from Brave Search API.', 'mxchat') . '</p>';
9079 }
9080
9081 public function mxchat_brave_image_count_callback() {
9082 $brave_image_count = isset($this->options['brave_image_count'])
9083 ? intval($this->options['brave_image_count'])
9084 : 4;
9085 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9086
9087 echo '<div class="mxchat-field-wrapper">';
9088 echo sprintf(
9089 '<input type="number" id="brave_image_count" name="brave_image_count"
9090 value="%d" min="1" max="6" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9091 $brave_image_count,
9092 $nonce
9093 );
9094 echo '</div>';
9095 echo '<p class="description">' . __('Select the number of images to return (1-6).', 'mxchat') . '</p>';
9096 }
9097
9098 public function mxchat_brave_safe_search_callback() {
9099 $brave_safe_search = isset($this->options['brave_safe_search'])
9100 ? esc_attr($this->options['brave_safe_search'])
9101 : 'strict';
9102 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9103
9104 echo '<div class="mxchat-field-wrapper">';
9105 echo '<select id="brave_safe_search" name="brave_safe_search" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
9106 echo sprintf(
9107 '<option value="strict" %s>%s</option>',
9108 selected($brave_safe_search, 'strict', false),
9109 __('Strict', 'mxchat')
9110 );
9111 echo sprintf(
9112 '<option value="off" %s>%s</option>',
9113 selected($brave_safe_search, 'off', false),
9114 __('Off', 'mxchat')
9115 );
9116 echo '</select>';
9117 echo '</div>';
9118 echo '<p class="description">' .
9119 esc_html__('Set the Safe Search level for image searches. Brave Search only supports "Strict" and "Off" options.', 'mxchat') .
9120 '</p>';
9121 }
9122
9123 public function mxchat_brave_news_count_callback() {
9124 $brave_news_count = isset($this->options['brave_news_count'])
9125 ? intval($this->options['brave_news_count'])
9126 : 3;
9127 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9128
9129 echo '<div class="mxchat-field-wrapper">';
9130 echo sprintf(
9131 '<input type="number" id="brave_news_count" name="brave_news_count"
9132 value="%d" min="1" max="10" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9133 $brave_news_count,
9134 $nonce
9135 );
9136 echo '</div>';
9137 echo '<p class="description">' . esc_html__('Select the number of news articles to retrieve (1-10).', 'mxchat') . '</p>';
9138 }
9139
9140 public function mxchat_brave_country_callback() {
9141 $brave_country = isset($this->options['brave_country'])
9142 ? esc_attr($this->options['brave_country'])
9143 : 'us';
9144 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9145
9146 echo '<div class="mxchat-field-wrapper">';
9147 echo sprintf(
9148 '<input type="text" id="brave_country" name="brave_country"
9149 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9150 $brave_country,
9151 $nonce
9152 );
9153 echo '</div>';
9154 echo '<p class="description">' . esc_html__('Enter the country code (e.g., "us" for United States).', 'mxchat') . '</p>';
9155 }
9156
9157 public function mxchat_brave_language_callback() {
9158 $brave_language = isset($this->options['brave_language'])
9159 ? esc_attr($this->options['brave_language'])
9160 : 'en';
9161 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9162
9163 echo '<div class="mxchat-field-wrapper">';
9164 echo sprintf(
9165 '<input type="text" id="brave_language" name="brave_language"
9166 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9167 $brave_language,
9168 $nonce
9169 );
9170 echo '</div>';
9171 echo '<p class="description">' . esc_html__('Enter the language code (e.g., "en" for English).', 'mxchat') . '</p>';
9172 }
9173
9174
9175
9176
9177
9178 // Section Callback
9179 public function mxchat_pdf_intent_section_callback() {
9180 echo '<p>' . esc_html__('Configure the intent settings for the Chat with PDF feature.', 'mxchat') . '</p>';
9181 }
9182
9183 public function mxchat_chat_toolbar_toggle_callback() {
9184 // Get chat toolbar toggle value with fallback
9185 $chat_toolbar_toggle = isset($this->options['chat_toolbar_toggle']) ? $this->options['chat_toolbar_toggle'] : 'off';
9186 $checked = ($chat_toolbar_toggle === 'on') ? 'checked' : '';
9187
9188 // Output the toggle switch
9189 echo '<label class="toggle-switch">';
9190 echo sprintf(
9191 '<input type="checkbox" id="chat_toolbar_toggle" name="chat_toolbar_toggle" value="on" %s />',
9192 esc_attr($checked)
9193 );
9194 echo '<span class="slider"></span>';
9195 echo '</label>';
9196
9197 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>';
9198 }
9199
9200 /**
9201 * Callback for PDF upload button toggle setting
9202 */
9203 public function mxchat_show_pdf_upload_button_callback() {
9204 // Get toggle value with fallback
9205 $show_pdf_button = isset($this->options['show_pdf_upload_button']) ? $this->options['show_pdf_upload_button'] : 'on';
9206 $checked = ($show_pdf_button === 'on') ? 'checked' : '';
9207
9208 // Output the toggle switch
9209 echo '<label class="toggle-switch">';
9210 echo sprintf(
9211 '<input type="checkbox" id="show_pdf_upload_button" name="show_pdf_upload_button" value="on" %s />',
9212 esc_attr($checked)
9213 );
9214 echo '<span class="slider"></span>';
9215 echo '</label>';
9216
9217 echo '<p class="description">' . esc_html__('Enable to show the PDF upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
9218 }
9219
9220 /**
9221 * Callback for Word upload button toggle setting
9222 */
9223 public function mxchat_show_word_upload_button_callback() {
9224 // Get toggle value with fallback
9225 $show_word_button = isset($this->options['show_word_upload_button']) ? $this->options['show_word_upload_button'] : 'on';
9226 $checked = ($show_word_button === 'on') ? 'checked' : '';
9227
9228 // Output the toggle switch
9229 echo '<label class="toggle-switch">';
9230 echo sprintf(
9231 '<input type="checkbox" id="show_word_upload_button" name="show_word_upload_button" value="on" %s />',
9232 esc_attr($checked)
9233 );
9234 echo '<span class="slider"></span>';
9235 echo '</label>';
9236
9237 echo '<p class="description">' . esc_html__('Enable to show the Word document upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
9238 }
9239
9240 public function mxchat_pdf_intent_trigger_text_callback() {
9241 $default_text = __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
9242
9243 echo sprintf(
9244 '<textarea id="pdf_intent_trigger_text"
9245 name="pdf_intent_trigger_text"
9246 rows="3"
9247 cols="50"
9248 placeholder="%s">%s</textarea>',
9249 esc_attr__('Enter trigger text', 'mxchat'),
9250 isset($this->options['pdf_intent_trigger_text'])
9251 ? esc_textarea($this->options['pdf_intent_trigger_text'])
9252 : esc_textarea($default_text)
9253 );
9254 echo '<p class="description">' . esc_html__('Text displayed when the intent is triggered.', 'mxchat') . '</p>';
9255 }
9256
9257 public function mxchat_pdf_intent_success_text_callback() {
9258 $default_text = __("I've processed the PDF. What questions do you have about it?", 'mxchat');
9259
9260 echo sprintf(
9261 '<textarea id="pdf_intent_success_text"
9262 name="pdf_intent_success_text"
9263 rows="3"
9264 cols="50"
9265 placeholder="%s">%s</textarea>',
9266 esc_attr__('Enter success text', 'mxchat'),
9267 isset($this->options['pdf_intent_success_text'])
9268 ? esc_textarea($this->options['pdf_intent_success_text'])
9269 : esc_textarea($default_text)
9270 );
9271 echo '<p class="description">' . esc_html__('Text displayed when the intent is successful.', 'mxchat') . '</p>';
9272 }
9273
9274 public function mxchat_pdf_intent_error_text_callback() {
9275 $default_text = __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
9276
9277 echo sprintf(
9278 '<textarea id="pdf_intent_error_text"
9279 name="pdf_intent_error_text"
9280 rows="3"
9281 cols="50"
9282 placeholder="%s">%s</textarea>',
9283 esc_attr__('Enter error text', 'mxchat'),
9284 isset($this->options['pdf_intent_error_text'])
9285 ? esc_textarea($this->options['pdf_intent_error_text'])
9286 : esc_textarea($default_text)
9287 );
9288 echo '<p class="description">' . esc_html__('Text displayed when an error occurs during the intent.', 'mxchat') . '</p>';
9289 }
9290
9291 public function mxchat_pdf_max_pages_callback() {
9292 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
9293
9294 echo sprintf(
9295 '<input type="range"
9296 id="pdf_max_pages"
9297 name="pdf_max_pages"
9298 min="1"
9299 max="69"
9300 value="%d"
9301 class="range-slider" />',
9302 esc_attr($max_pages)
9303 );
9304 echo '<span id="pdf_max_pages_output">' . esc_html($max_pages) . '</span>';
9305 echo '<p class="description">' . esc_html__('Set the maximum number of document pages users can upload for processing. (1-69 pages)', 'mxchat') . '</p>';
9306 }
9307
9308 public function mxchat_live_agent_status_callback() {
9309 // Always get fresh options instead of using cached $this->options
9310 $fresh_options = get_option('mxchat_options');
9311 $status = isset($fresh_options['live_agent_status']) ? $fresh_options['live_agent_status'] : 'off';
9312
9313 echo '<label class="toggle-switch">';
9314 echo sprintf(
9315 '<input type="checkbox" id="live_agent_status" name="live_agent_status" value="on" %s />',
9316 checked($status, 'on', false)
9317 );
9318 echo '<span class="slider"></span>';
9319 echo '</label>';
9320 echo '<label for="live_agent_status" class="mxchat-status-label">';
9321 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9322 echo '</label>';
9323 }
9324
9325 /**
9326 * Availability-schedule editor (plans 8ccaa2 + 99d7a4).
9327 *
9328 * ONE callback, parameterized by channel ('slack' | 'telegram' via the
9329 * add_settings_field $args) — the markup and CSS are shared, only the bound
9330 * option and the helper text differ. Each channel's editor renders under its
9331 * own Integrations tab and governs ONLY that channel's handoff. While the
9332 * master toggle is off the day grid is inert and handoff availability stays
9333 * governed solely by that channel's manual status toggle.
9334 *
9335 * The day inputs carry NO name attribute and are marked .mxchat-la-field so the
9336 * generic autosave skips them; the editor JS folds them into the channel's
9337 * hidden live_agent_schedule_<channel> input and fires one change, reusing the
9338 * existing autosave transport rather than growing a second one. All hooks the
9339 * JS needs are CLASSES scoped inside .mxchat-la-schedule, never ids — the
9340 * markup exists twice on the page.
9341 */
9342 public function mxchat_live_agent_schedule_callback($args = array()) {
9343 if (!class_exists('MxChat_Live_Agent_Schedule')) {
9344 return;
9345 }
9346 $channel = (isset($args['channel']) && in_array($args['channel'], MxChat_Live_Agent_Schedule::channels(), true))
9347 ? $args['channel']
9348 : 'slack';
9349 $field = 'live_agent_schedule_' . $channel;
9350 $sched = MxChat_Live_Agent_Schedule::get($channel);
9351 $labels = MxChat_Live_Agent_Schedule::day_labels();
9352 $tz = MxChat_Live_Agent_Schedule::timezone_label();
9353 $enabled = !empty($sched['enabled']);
9354 $channel_labels = array(
9355 'slack' => __('Slack', 'mxchat'),
9356 'telegram' => __('Telegram', 'mxchat'),
9357 'webhook' => __('Webhook', 'mxchat'),
9358 );
9359 $channel_label = $channel_labels[$channel];
9360 ?>
9361 <div class="mxchat-la-schedule<?php echo $enabled ? ' is-active' : ''; ?>"
9362 id="mxchat-la-schedule-<?php echo esc_attr($channel); ?>"
9363 data-channel="<?php echo esc_attr($channel); ?>">
9364 <label class="toggle-switch">
9365 <input type="checkbox" id="<?php echo esc_attr($field); ?>_enabled"
9366 class="mxchat-la-field mxchat-la-enabled" <?php checked($enabled); ?> />
9367 <span class="slider"></span>
9368 </label>
9369 <label for="<?php echo esc_attr($field); ?>_enabled" class="mxchat-status-label">
9370 <span class="status-text mxchat-la-status-text">
9371 <?php echo $enabled
9372 ? esc_html__('Scheduled hours', 'mxchat')
9373 : esc_html__('Always available', 'mxchat'); ?>
9374 </span>
9375 </label>
9376
9377 <div class="mxchat-la-days" aria-hidden="<?php echo $enabled ? 'false' : 'true'; ?>">
9378 <?php foreach ($labels as $n => $label) :
9379 $day = $sched['days'][$n];
9380 $on = !empty($day['enabled']);
9381 ?>
9382 <div class="mxchat-la-day<?php echo $on ? ' is-on' : ''; ?>" data-day="<?php echo esc_attr($n); ?>">
9383 <label class="mxchat-la-day-label">
9384 <input type="checkbox" class="mxchat-la-field mxchat-la-day-enabled"
9385 data-day="<?php echo esc_attr($n); ?>" <?php checked($on); ?> />
9386 <span class="mxchat-la-day-name"><?php echo esc_html($label); ?></span>
9387 </label>
9388 <div class="mxchat-la-times">
9389 <input type="time" class="mxchat-la-field mxchat-la-start"
9390 data-day="<?php echo esc_attr($n); ?>"
9391 value="<?php echo esc_attr($day['start']); ?>"
9392 aria-label="<?php echo esc_attr(sprintf(__('%s start time', 'mxchat'), $label)); ?>" />
9393 <span class="mxchat-la-dash">&ndash;</span>
9394 <input type="time" class="mxchat-la-field mxchat-la-end"
9395 data-day="<?php echo esc_attr($n); ?>"
9396 value="<?php echo esc_attr($day['end']); ?>"
9397 aria-label="<?php echo esc_attr(sprintf(__('%s end time', 'mxchat'), $label)); ?>" />
9398 </div>
9399 </div>
9400 <?php endforeach; ?>
9401 </div>
9402
9403 <input type="hidden" name="<?php echo esc_attr($field); ?>" id="<?php echo esc_attr($field); ?>"
9404 class="mxchat-la-hidden" value="<?php echo esc_attr(wp_json_encode($sched)); ?>" />
9405 </div>
9406 <p class="description">
9407 <?php printf(
9408 /* translators: 1: the handoff channel, e.g. Slack or Telegram; 2: the site's timezone, e.g. America/New_York */
9409 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'),
9410 '<strong>' . esc_html($channel_label) . '</strong>',
9411 '<strong>' . esc_html($tz) . '</strong>'
9412 ); ?>
9413 </p>
9414 <?php
9415 }
9416
9417 public function mxchat_live_agent_away_message_callback() {
9418 $message = isset($this->options['live_agent_away_message'])
9419 ? $this->options['live_agent_away_message']
9420 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9421
9422 printf(
9423 '<textarea id="live_agent_away_message" name="live_agent_away_message" rows="3" cols="50">%s</textarea>',
9424 esc_textarea($message)
9425 );
9426 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9427 }
9428
9429 public function mxchat_live_agent_notification_message_callback() {
9430 $message = isset($this->options['live_agent_notification_message'])
9431 ? $this->options['live_agent_notification_message']
9432 : __('Live agent has been notified.', 'mxchat');
9433
9434 printf(
9435 '<textarea id="live_agent_notification_message" name="live_agent_notification_message" rows="3" cols="50">%s</textarea>',
9436 esc_textarea($message)
9437 );
9438 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9439 }
9440
9441 public function mxchat_live_agent_webhook_url_callback() {
9442 $webhook_url = isset($this->options['live_agent_webhook_url'])
9443 ? esc_url($this->options['live_agent_webhook_url'])
9444 : esc_url(get_option('live_agent_webhook_url', ''));
9445
9446 printf(
9447 '<input type="password" id="live_agent_webhook_url" name="live_agent_webhook_url" value="%s" class="regular-text" />',
9448 $webhook_url
9449 );
9450 echo '<button type="button" id="toggleWebhookUrlVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9451 echo '<p class="description">' . esc_html__('Enter your Slack webhook URL for live agent notifications.', 'mxchat') . '</p>';
9452 }
9453
9454 public function mxchat_live_agent_secret_key_callback() {
9455 printf(
9456 '<input type="password" id="live_agent_secret_key" name="live_agent_secret_key" value="%s" class="regular-text" />',
9457 isset($this->options['live_agent_secret_key']) ? esc_attr($this->options['live_agent_secret_key']) : ''
9458 );
9459 echo '<button type="button" id="toggleSecretKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9460 echo '<p class="description">' . esc_html__('Secret key for validating Slack requests. Keep this secure.', 'mxchat') . '</p>';
9461 }
9462
9463 public function mxchat_live_agent_bot_token_callback() {
9464 printf(
9465 '<input type="password" id="live_agent_bot_token" name="live_agent_bot_token" value="%s" class="regular-text" />',
9466 isset($this->options['live_agent_bot_token']) ? esc_attr($this->options['live_agent_bot_token']) : ''
9467 );
9468 echo '<button type="button" id="toggleBotTokenVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9469 echo '<p class="description">' . esc_html__('Your Slack Bot OAuth Token (starts with xoxb-). Keep this secure.', 'mxchat') . '</p>';
9470 }
9471
9472 public function mxchat_live_agent_user_ids_callback() {
9473 $user_ids = isset($this->options['live_agent_user_ids'])
9474 ? esc_textarea($this->options['live_agent_user_ids'])
9475 : '';
9476
9477 printf(
9478 '<textarea id="live_agent_user_ids" name="live_agent_user_ids" rows="4" class="large-text">%s</textarea>',
9479 $user_ids
9480 );
9481 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>';
9482 }
9483
9484 public function mxchat_live_agent_shared_channel_callback() {
9485 $value = isset($this->options['live_agent_shared_channel']) ? $this->options['live_agent_shared_channel'] : '';
9486 printf(
9487 '<input type="text" id="live_agent_shared_channel" name="live_agent_shared_channel" value="%s" class="regular-text" placeholder="#support or C0123456789" />',
9488 esc_attr($value)
9489 );
9490 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>';
9491
9492 // Surface the last failed handoff so a wrong name / missing invite is
9493 // visible right where it gets fixed. A successful handoff clears this.
9494 $shared_error = get_option('mxchat_slack_shared_channel_error');
9495 if (!empty($shared_error['error']) && trim((string) $value) !== '' && ($shared_error['configured'] ?? '') === trim((string) $value)) {
9496 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this channel', 'mxchat') . '</strong> — '
9497 . esc_html(sprintf(
9498 /* translators: %s: Slack API error code */
9499 __('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'),
9500 $shared_error['error']
9501 )) . '</p>';
9502 }
9503
9504 // Private-channel warning (plan 1a2666), recorded by the privacy probe at
9505 // configuration time or when the channel id first resolves. Posting INTO
9506 // a private channel works once the bot is a member, so everything looks
9507 // configured — but Slack delivers inbound replies from private channels
9508 // as message.groups events, which the documented app setup never
9509 // subscribes to, and agent replies silently never reach the visitor.
9510 $shared_privacy = get_option('mxchat_slack_shared_channel_privacy');
9511 if (!empty($shared_privacy['is_private']) && trim((string) $value) !== '' && ($shared_privacy['configured'] ?? '') === trim((string) $value)) {
9512 echo '<p class="description"><strong>' . esc_html__('⚠ This is a private channel', 'mxchat') . '</strong> — '
9513 . esc_html__('Slack sends replies from private channels as message.groups events. In your Slack app, add the groups:history scope and subscribe to the message.groups bot event (alongside message.channels), then reinstall the app — otherwise agent replies posted here never reach your visitors.', 'mxchat') . '</p>';
9514 }
9515 }
9516
9517 public function mxchat_live_agent_archive_on_end_callback() {
9518 $value = isset($this->options['live_agent_archive_on_end_toggle']) ? $this->options['live_agent_archive_on_end_toggle'] : 'off';
9519 printf(
9520 '<input type="checkbox" id="live_agent_archive_on_end_toggle" name="live_agent_archive_on_end_toggle" value="on" %s />',
9521 checked('on', $value, false)
9522 );
9523 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>';
9524 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>';
9525 }
9526
9527 /**
9528 * Telegram Integration Callbacks
9529 */
9530 public function mxchat_telegram_section_callback() {
9531 echo '<p>' . esc_html__('Configure Telegram integration for live agent support.', 'mxchat') . '</p>';
9532 }
9533
9534 public function mxchat_telegram_status_callback() {
9535 $fresh_options = get_option('mxchat_options');
9536 $status = isset($fresh_options['telegram_status']) ? $fresh_options['telegram_status'] : 'off';
9537
9538 echo '<label class="toggle-switch">';
9539 echo sprintf(
9540 '<input type="checkbox" id="telegram_status" name="telegram_status" value="on" %s />',
9541 checked($status, 'on', false)
9542 );
9543 echo '<span class="slider"></span>';
9544 echo '</label>';
9545 echo '<label for="telegram_status" class="mxchat-status-label">';
9546 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9547 echo '</label>';
9548 }
9549
9550 public function mxchat_telegram_notification_message_callback() {
9551 $message = isset($this->options['telegram_notification_message'])
9552 ? $this->options['telegram_notification_message']
9553 : __("I've notified a support agent. Please allow a moment for them to respond.", 'mxchat');
9554
9555 printf(
9556 '<textarea id="telegram_notification_message" name="telegram_notification_message" rows="3" cols="50">%s</textarea>',
9557 esc_textarea($message)
9558 );
9559 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9560 }
9561
9562 public function mxchat_telegram_away_message_callback() {
9563 $message = isset($this->options['telegram_away_message'])
9564 ? $this->options['telegram_away_message']
9565 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9566
9567 printf(
9568 '<textarea id="telegram_away_message" name="telegram_away_message" rows="3" cols="50">%s</textarea>',
9569 esc_textarea($message)
9570 );
9571 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9572 }
9573
9574 public function mxchat_telegram_bot_token_callback() {
9575 printf(
9576 '<input type="password" id="telegram_bot_token" name="telegram_bot_token" value="%s" class="regular-text" />',
9577 isset($this->options['telegram_bot_token']) ? esc_attr($this->options['telegram_bot_token']) : ''
9578 );
9579 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>';
9580 echo '<p class="description">' . esc_html__('Your Telegram Bot Token from @BotFather (e.g., 123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ).', 'mxchat') . '</p>';
9581 }
9582
9583 public function mxchat_telegram_group_id_callback() {
9584 printf(
9585 '<input type="text" id="telegram_group_id" name="telegram_group_id" value="%s" class="regular-text" />',
9586 isset($this->options['telegram_group_id']) ? esc_attr($this->options['telegram_group_id']) : ''
9587 );
9588 echo '<p class="description">' . esc_html__('Your Telegram supergroup ID (starts with -100). The group must have forum topics enabled.', 'mxchat') . '</p>';
9589
9590 // Surface the last failed Telegram API call right where it gets fixed
9591 // (plan 1aeee3). Before this existed every Telegram response was
9592 // discarded — a wrong group id, a closed topic, or an oversized handoff
9593 // all failed with no trace anywhere. Kept until the next failure
9594 // overwrites it; the timestamp shows how stale it is.
9595 $tg_error = get_option('mxchat_telegram_last_error');
9596 if (!empty($tg_error['error'])) {
9597 echo '<p class="description"><strong>' . esc_html__('⚠ Last Telegram API failure', 'mxchat') . '</strong> — '
9598 . esc_html(sprintf(
9599 /* translators: 1: Telegram API method name, 2: Telegram's error description, 3: local date and time of the failure */
9600 __('%1$s failed with "%2$s" (%3$s). If live agent chat is working now, this may be from an earlier misconfiguration.', 'mxchat'),
9601 $tg_error['method'] ?? 'sendMessage',
9602 $tg_error['error'],
9603 !empty($tg_error['time']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $tg_error['time']) : __('unknown time', 'mxchat')
9604 )) . '</p>';
9605 }
9606
9607 // The inbound counterpart of the line above (plan 30398e): outbound tells you
9608 // MxChat could not reach Telegram, this one tells you Telegram reached MxChat
9609 // and was turned away. That failure previously left nothing behind but a
9610 // single error_log() line, so a Cloudflare-fronted install with no secret set
9611 // dropped every agent reply with no admin-side trace at all. Cleared
9612 // automatically by the first accepted request.
9613 $tg_rejects = get_option('mxchat_telegram_webhook_rejects');
9614 if (is_array($tg_rejects) && !empty($tg_rejects['count'])) {
9615 $reject_count = (int) $tg_rejects['count'];
9616 echo '<p class="description" style="color:#b32d2e;"><strong>' . esc_html__('⚠ Incoming Telegram webhook requests are being rejected', 'mxchat') . '</strong> — '
9617 . esc_html(sprintf(
9618 /* translators: 1: number of rejected requests, 2: local date and time of the most recent rejection */
9619 _n(
9620 '%1$s request rejected, most recently %2$s. Agent replies are not reaching visitors.',
9621 '%1$s requests rejected, most recently %2$s. Agent replies are not reaching visitors.',
9622 $reject_count,
9623 'mxchat'
9624 ),
9625 number_format_i18n($reject_count),
9626 !empty($tg_rejects['last_ts']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $tg_rejects['last_ts']) : __('unknown time', 'mxchat')
9627 ))
9628 . ' ' . esc_html($this->mxchat_telegram_reject_advice($tg_rejects['last_reason'] ?? ''))
9629 . '</p>';
9630 }
9631 }
9632
9633 public function mxchat_telegram_webhook_secret_callback() {
9634 $secret = isset($this->options['telegram_webhook_secret']) ? $this->options['telegram_webhook_secret'] : '';
9635
9636 // Auto-generate secret if empty
9637 if (empty($secret)) {
9638 $secret = wp_generate_password(64, false, false);
9639 $options = get_option('mxchat_options', []);
9640 $options['telegram_webhook_secret'] = $secret;
9641 update_option('mxchat_options', $options);
9642 $this->options['telegram_webhook_secret'] = $secret;
9643 }
9644
9645 printf(
9646 '<input type="password" id="telegram_webhook_secret" name="telegram_webhook_secret" value="%s" class="regular-text" />',
9647 esc_attr($secret)
9648 );
9649 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>';
9650 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>';
9651 }
9652
9653 /**
9654 * Webhook Handoff Callbacks (plan d88e22)
9655 */
9656 public function mxchat_webhook_handoff_section_callback() {
9657 echo '<p>' . esc_html__('Send live-agent handoffs to any URL you choose — your helpdesk, an automation flow, a CRM.', 'mxchat') . '</p>';
9658 }
9659
9660 public function mxchat_webhook_handoff_status_callback() {
9661 $fresh_options = get_option('mxchat_options');
9662 $status = isset($fresh_options['webhook_handoff_status']) ? $fresh_options['webhook_handoff_status'] : 'off';
9663
9664 echo '<label class="toggle-switch">';
9665 echo sprintf(
9666 '<input type="checkbox" id="webhook_handoff_status" name="webhook_handoff_status" value="on" %s />',
9667 checked($status, 'on', false)
9668 );
9669 echo '<span class="slider"></span>';
9670 echo '</label>';
9671 echo '<label for="webhook_handoff_status" class="mxchat-status-label">';
9672 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9673 echo '</label>';
9674 }
9675
9676 public function mxchat_webhook_handoff_notification_message_callback() {
9677 $message = isset($this->options['webhook_handoff_notification_message'])
9678 ? $this->options['webhook_handoff_notification_message']
9679 : __("I've notified our support team — they'll follow up with you soon. Meanwhile, I'm happy to keep helping.", 'mxchat');
9680
9681 printf(
9682 '<textarea id="webhook_handoff_notification_message" name="webhook_handoff_notification_message" rows="3" cols="50">%s</textarea>',
9683 esc_textarea($message)
9684 );
9685 echo '<p class="description">' . esc_html__('Message shown after the handoff is delivered. The chat stays in AI mode — your team follows up separately, so let the visitor know what to expect.', 'mxchat') . '</p>';
9686 }
9687
9688 public function mxchat_webhook_handoff_away_message_callback() {
9689 $message = isset($this->options['webhook_handoff_away_message'])
9690 ? $this->options['webhook_handoff_away_message']
9691 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9692
9693 printf(
9694 '<textarea id="webhook_handoff_away_message" name="webhook_handoff_away_message" rows="3" cols="50">%s</textarea>',
9695 esc_textarea($message)
9696 );
9697 echo '<p class="description">' . esc_html__('Message shown when this destination is offline or outside its scheduled hours.', 'mxchat') . '</p>';
9698 }
9699
9700 public function mxchat_webhook_handoff_url_callback() {
9701 $value = isset($this->options['webhook_handoff_url']) ? $this->options['webhook_handoff_url'] : '';
9702 printf(
9703 '<input type="url" id="webhook_handoff_url" name="webhook_handoff_url" value="%s" class="regular-text" placeholder="https://example.com/hooks/mxchat" />',
9704 esc_attr($value)
9705 );
9706 echo '<p class="description">' . esc_html__('Where handoffs are sent as a JSON POST. Must be https and publicly reachable (localhost and private-network hosts are rejected).', 'mxchat') . '</p>';
9707
9708 // Surface the last failed delivery right where it gets fixed — a handoff
9709 // that silently 404s is worse than no handoff. A successful one clears this.
9710 $webhook_error = get_option('mxchat_webhook_handoff_error');
9711 if (!empty($webhook_error['error']) && trim((string) $value) !== '' && ($webhook_error['configured'] ?? '') === trim((string) $value)) {
9712 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this URL', 'mxchat') . '</strong> — '
9713 . esc_html(sprintf(
9714 /* translators: %s: the delivery failure, e.g. an HTTP status or a transport error */
9715 __('the request failed with "%s". Until this is fixed, the chatbot answers these conversations itself instead of telling visitors a human was notified.', 'mxchat'),
9716 $webhook_error['error']
9717 )) . '</p>';
9718 }
9719 }
9720
9721 public function mxchat_webhook_handoff_secret_callback() {
9722 printf(
9723 '<input type="password" id="webhook_handoff_secret" name="webhook_handoff_secret" value="%s" class="regular-text" />',
9724 isset($this->options['webhook_handoff_secret']) ? esc_attr($this->options['webhook_handoff_secret']) : ''
9725 );
9726 echo '<button type="button" class="button button-secondary" onclick="var f=document.getElementById(\'webhook_handoff_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>';
9727 echo '<p class="description">' . esc_html__('Optional. When set, each POST carries an X-MxChat-Signature header (sha256 HMAC of the body) so your endpoint can verify the sender. The secret itself is never sent.', 'mxchat') . '</p>';
9728 }
9729
9730 public function mxchat_similarity_threshold_callback() {
9731 // Load from mxchat_options array
9732 $options = get_option('mxchat_options', []);
9733
9734 // Get value from options array with default of 35
9735 $threshold = isset($options['similarity_threshold']) ? $options['similarity_threshold'] : 35;
9736
9737 echo '<div class="slider-container">';
9738 echo sprintf(
9739 '<input type="range"
9740 id="similarity_threshold"
9741 name="similarity_threshold"
9742 min="20"
9743 max="85"
9744 step="1"
9745 value="%s"
9746 class="range-slider" />',
9747 esc_attr($threshold)
9748 );
9749 echo sprintf(
9750 '<span id="threshold_value" class="range-value">%s</span>',
9751 esc_html($threshold)
9752 );
9753 echo '</div>';
9754 }
9755
9756 public function mxchat_max_input_length_callback() {
9757 // Load from mxchat_options array (plan a3fae2 part C).
9758 $options = get_option('mxchat_options', []);
9759
9760 // 0 = unlimited (default — preserves current behavior, no cap).
9761 $max_input_length = isset($options['max_input_length']) ? intval($options['max_input_length']) : 0;
9762
9763 echo sprintf(
9764 '<input type="number"
9765 id="max_input_length"
9766 name="max_input_length"
9767 min="0"
9768 max="100000"
9769 step="1"
9770 value="%s"
9771 placeholder="0"
9772 class="mxch-input mxch-input-sm" />',
9773 esc_attr($max_input_length)
9774 );
9775 }
9776
9777 public function mxchat_rag_sources_limit_callback() {
9778 // Load from mxchat_options array
9779 $options = get_option('mxchat_options', []);
9780
9781 // Get value from options array with default of 3
9782 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
9783
9784 echo '<div class="slider-container">';
9785 echo sprintf(
9786 '<input type="range"
9787 id="rag_sources_limit"
9788 name="rag_sources_limit"
9789 min="3"
9790 max="10"
9791 step="1"
9792 value="%s"
9793 class="range-slider" />',
9794 esc_attr($rag_sources_limit)
9795 );
9796 echo sprintf(
9797 '<span id="rag_sources_limit_value" class="range-value">%s</span>',
9798 esc_html($rag_sources_limit)
9799 );
9800 echo '</div>';
9801 }
9802
9803 public function mxchat_rag_chunks_limit_callback() {
9804 // Load from mxchat_options array
9805 $options = get_option('mxchat_options', []);
9806
9807 // Get value from options array with default of 15
9808 $rag_chunks_limit = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
9809
9810 echo '<div class="slider-container">';
9811 echo sprintf(
9812 '<input type="range"
9813 id="rag_chunks_limit"
9814 name="rag_chunks_limit"
9815 min="8"
9816 max="20"
9817 step="1"
9818 value="%s"
9819 class="range-slider" />',
9820 esc_attr($rag_chunks_limit)
9821 );
9822 echo sprintf(
9823 '<span id="rag_chunks_limit_value" class="range-value">%s</span>',
9824 esc_html($rag_chunks_limit)
9825 );
9826 echo '</div>';
9827 }
9828
9829 /**
9830 * Add body class on the Onboarding wizard page so CSS-only chrome surgery
9831 * (collapsing the WP admin sidebar) only applies on this page.
9832 * Plan: plan-mxchat-20260527-905439.
9833 */
9834 public function mxchat_add_onboarding_body_class($classes) {
9835 if (isset($_GET['page']) && $_GET['page'] === 'mxchat-onboarding') {
9836 $classes .= ' mxchat-onboarding-focused';
9837 }
9838 return $classes;
9839 }
9840
9841 public function mxchat_enqueue_admin_assets($hook_suffix = '') {
9842 // Authorization gate (plan-mxchat-20260731-c63fb6). Previously the ONLY
9843 // guard here was the strpos() on $_GET['page'] below — which is
9844 // attacker-controlled — so ANY logged-in user (Subscriber included) could
9845 // load a page they are legitimately allowed to see, e.g.
9846 // /wp-admin/profile.php?page=mxchat, and receive every nonce this method
9847 // localizes. Three of the handlers behind those nonces verified the nonce
9848 // but checked no capability. Every MxChat menu page is registered
9849 // 'manage_options' (see mxchat_add_admin_menu), so this is a no-op for
9850 // legitimate users.
9851 if (!current_user_can('manage_options')) {
9852 return;
9853 }
9854
9855 // Get plugin version
9856 $version = MXCHAT_VERSION;
9857
9858 // Use file modification time for development (remove in production)
9859 if (defined('WP_DEBUG') && WP_DEBUG) {
9860 $version = filemtime(plugin_dir_path(__FILE__) . '../mxchat-basic.php');
9861 }
9862
9863 $current_page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
9864 $plugin_url = plugin_dir_url(__FILE__) . '../';
9865
9866 // Only load on MxChat pages. Prefer the server-derived $hook_suffix that
9867 // admin_enqueue_scripts passes us — unlike $_GET['page'] it cannot be
9868 // forged onto a screen MxChat does not own. Falls back to the legacy
9869 // $_GET check only when the hook is unavailable (direct/legacy callers).
9870 if ($hook_suffix !== '') {
9871 if (strpos($hook_suffix, 'mxchat') === false) {
9872 return;
9873 }
9874 } elseif (strpos($current_page, 'mxchat') === false) {
9875 return;
9876 }
9877
9878 // Always load these on all MxChat pages
9879 $this->enqueue_core_admin_assets($plugin_url, $version);
9880 $this->enqueue_page_specific_assets($current_page, $plugin_url, $version);
9881 $this->localize_admin_scripts($current_page);
9882 }
9883 private function enqueue_core_admin_assets($plugin_url, $version) {
9884 // Core admin styles
9885 wp_enqueue_style('mxchat-admin-css', $plugin_url . 'css/admin-style.css', array(), $version);
9886 wp_enqueue_style('mxchat-knowledge-css', $plugin_url . 'css/knowledge-style.css', array(), $version);
9887
9888 // New sidebar navigation styles (for main settings page)
9889 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9890
9891 // Core admin scripts
9892 wp_enqueue_script('mxchat-admin-js', $plugin_url . 'js/mxchat-admin.js', array('jquery'), $version, true);
9893 }
9894 private function enqueue_page_specific_assets($current_page, $plugin_url, $version) {
9895 switch ($current_page) {
9896 case 'mxchat-prompts':
9897 // Shared shell JS: tab switching, deep-linking and the mobile menu
9898 // for this page's flat nav (inline copies removed by plan c192a4).
9899 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9900 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9901 'copied' => __('Copied', 'mxchat'),
9902 ));
9903 // Knowledge processing page assets
9904 wp_enqueue_style('mxchat-content-selector-css', $plugin_url . 'css/content-selector.css', array(), $version);
9905 wp_enqueue_script('mxchat-content-selector-js', $plugin_url . 'js/content-selector.js', array('jquery'), $version, true);
9906 // Add the knowledge processing script (common script needed for WordPress dismiss functionality)
9907 wp_enqueue_script('mxchat-knowledge-processing', $plugin_url . 'js/knowledge-processing.js', array('jquery', 'common'), $version, true);
9908 // Per-entry "View indexed content" inspector (plan-d8cb4b) reuses the
9909 // Testing tab's match-card / chunk-detail components, which are scoped
9910 // under .mxch-testing-results. Load that stylesheet here so the inspector
9911 // modal renders identically to the Testing tab.
9912 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css'), $version);
9913 break;
9914
9915 case 'mxchat-transcripts':
9916 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9917 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9918 // Load transcripts-specific styles
9919 wp_enqueue_style('mxchat-chat-transcripts-css', $plugin_url . 'css/chat-transcripts.css', array('mxchat-admin-sidebar-css'), $version);
9920 wp_enqueue_script('mxchat-transcripts-js', $plugin_url . 'js/mxchat_transcripts.js', array('jquery'), $version, true);
9921 break;
9922
9923 case 'mxchat-actions':
9924 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9925 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9926 // Load actions-specific styles
9927 wp_enqueue_style('mxchat-actions-css', $plugin_url . 'css/actions.css', array('mxchat-admin-sidebar-css'), $version);
9928 wp_enqueue_script('mxchat-actions-js', $plugin_url . 'js/mxchat_actions.js', array('jquery'), $version, true);
9929
9930 // Localize script data for actions page
9931 $is_activated = get_option('mxchat_pro_license_status') === 'active';
9932 wp_localize_script('mxchat-actions-js', 'mxchActionsData', array(
9933 'ajaxUrl' => admin_url('admin-ajax.php'),
9934 'nonce' => wp_create_nonce('mxchat_actions_nonce'),
9935 'addNonce' => wp_create_nonce('mxchat_add_intent_nonce'),
9936 'editNonce' => wp_create_nonce('mxchat_edit_intent'),
9937 'deleteNonce' => wp_create_nonce('mxchat_delete_intent_nonce'),
9938 'toggleNonce' => wp_create_nonce('mxchat_actions_nonce'),
9939 'addPhraseNonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
9940 'deletePhraseNonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
9941 'getPhrasesNonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
9942 'deleteLegacyNonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
9943 'isActivated' => $is_activated,
9944 'i18n' => array(
9945 'confirmDelete' => __('Are you sure you want to delete this trigger phrase?', 'mxchat'),
9946 'confirmBulkDelete' => __('Are you sure you want to delete the selected trigger phrases?', 'mxchat'),
9947 'saving' => __('Saving...', 'mxchat'),
9948 'saved' => __('Saved successfully!', 'mxchat'),
9949 'error' => __('An error occurred. Please try again.', 'mxchat'),
9950 'proRequired' => __('This feature requires MxChat Pro.', 'mxchat'),
9951 'addonRequired' => __('This feature requires an add-on.', 'mxchat')
9952 )
9953 ));
9954 break;
9955
9956 case 'mxchat-activation':
9957 // Load admin sidebar CSS first (shared styles for sidebar navigation)
9958 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9959 // Load pro page-specific styles
9960 wp_enqueue_style('mxchat-pro-css', $plugin_url . 'css/admin-pro.css', array('mxchat-admin-sidebar-css'), $version);
9961 // Load pro page JavaScript
9962 wp_enqueue_script('mxchat-pro-js', $plugin_url . 'js/mxchat_pro.js', array('jquery'), $version, true);
9963 // Load activation script for license activation/deactivation
9964 wp_enqueue_script('mxchat-activation-js', $plugin_url . 'js/activation-script.js', array('jquery'), $version, true);
9965 break;
9966
9967 case 'mxchat-content':
9968 // Load admin sidebar CSS (shared styles for sidebar navigation)
9969 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9970 // Load content page-specific styles
9971 wp_enqueue_style('mxchat-content-css', $plugin_url . 'css/admin-content.css', array('mxchat-admin-sidebar-css'), $version);
9972 // Load content page JavaScript
9973 wp_enqueue_script('mxchat-content-js', $plugin_url . 'js/mxchat-content.js', array('jquery'), $version, true);
9974 break;
9975
9976 case 'mxchat-api-access':
9977 // Shared sidebar shell (CSS + JS for tab switching / mobile menu / copy buttons).
9978 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9979 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9980 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9981 'copied' => __('Copied', 'mxchat'),
9982 ));
9983 break;
9984
9985 case 'mxchat-max':
9986 case 'mxchat-onboarding':
9987 // Onboarding page — uses the shared admin shell PLUS the wizard
9988 // overlay (plan-905439). admin-onboarding-wizard.css scopes the
9989 // WP-chrome surgery to body.mxchat-onboarding-focused so it only
9990 // applies on THIS page. The body class is added below via the
9991 // admin_body_class filter.
9992 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
9993 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
9994 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
9995 'copied' => __('Copied', 'mxchat'),
9996 ));
9997 // admin-style.css provides the .mxchat-instructions-modal-* classes
9998 // the new Behavior step's "View Sample Instructions" modal needs.
9999 // Enqueued AFTER admin-sidebar.css but BEFORE the wizard overlay
10000 // so the wizard's own rules win where they collide. plan-a2e4d6.
10001 wp_enqueue_style('mxchat-admin-style-css', $plugin_url . 'css/admin-style.css', array('mxchat-admin-sidebar-css'), $version);
10002 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);
10003 wp_enqueue_script('mxchat-admin-onboarding-wizard-js', $plugin_url . 'js/admin-onboarding-wizard.js', array(), $version, true);
10004 break;
10005 default:
10006 // Settings page: load the shared shell JS for #hash deep-linking
10007 // to tabs (plan 4ede16). The page's own inline nav script owns
10008 // clicks/accordion behaviour; the shell script adds load +
10009 // hashchange activation and replaceState so plain href="#tab"
10010 // anchors and shared URLs land on the right tab. Kept inside the
10011 // default case because the settings page also needs everything
10012 // below (Testing tab chatbot + streaming assets).
10013 if ($current_page === 'mxchat-settings') {
10014 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10015 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10016 'copied' => __('Copied', 'mxchat'),
10017 ));
10018 }
10019
10020 wp_enqueue_script(
10021 'mxchat-test-streaming-js',
10022 $plugin_url . 'js/mxchat-test-streaming.js',
10023 ['jquery'],
10024 $version,
10025 true
10026 );
10027
10028 wp_localize_script('mxchat-test-streaming-js', 'mxchatTestStreamingAjax', [
10029 'ajax_url' => admin_url('admin-ajax.php'),
10030 'nonce' => wp_create_nonce('mxchat_test_streaming_nonce'),
10031 'settings_nonce' => wp_create_nonce('mxchat_save_setting_nonce')
10032 ]);
10033
10034 // Testing Tab: Load chatbot assets for the embedded testing chatbot
10035 wp_enqueue_style('mxchat-chat-css', $plugin_url . 'css/chat-style.css', array(), $version);
10036 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css', 'mxchat-chat-css'), $version);
10037
10038 wp_enqueue_script('mxchat-chat-js', $plugin_url . 'js/chat-script.js', array('jquery'), $version, true);
10039 wp_enqueue_script('mxchat-admin-testing-js', $plugin_url . 'js/admin-testing-tab.js', array('jquery', 'mxchat-chat-js'), $version, true);
10040
10041 // Allow add-ons to enqueue their public CSS/JS for the testing chatbot
10042 do_action('mxchat_enqueue_testing_tab_assets');
10043
10044 // Localize mxchatChat for the chatbot JS (same settings as frontend)
10045 $options = get_option('mxchat_options', array());
10046 $prompts_options = get_option('mxchat_prompts_options', array());
10047 $theme_options = get_option('mxchat_theme_options', array());
10048 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
10049 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
10050 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
10051
10052 wp_localize_script('mxchat-chat-js', 'mxchatChat', array(
10053 'ajax_url' => admin_url('admin-ajax.php'),
10054 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
10055 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
10056 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
10057 'contextual_awareness_toggle' => isset($options['contextual_awareness_toggle']) ? $options['contextual_awareness_toggle'] : 'off',
10058 'link_target_toggle' => $options['link_target_toggle'] ?? 'off',
10059 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
10060 'complianz_toggle' => isset($options['complianz_toggle']) && $options['complianz_toggle'] === 'on',
10061 'user_message_bg_color' => $options['user_message_bg_color'] ?? '#fff',
10062 'user_message_font_color' => $options['user_message_font_color'] ?? '#212121',
10063 'bot_message_bg_color' => $options['bot_message_bg_color'] ?? '#212121',
10064 'bot_message_font_color' => $options['bot_message_font_color'] ?? '#fff',
10065 'top_bar_bg_color' => $options['top_bar_bg_color'] ?? '#212121',
10066 'send_button_font_color' => $options['send_button_font_color'] ?? '#212121',
10067 'close_button_color' => $options['close_button_color'] ?? '#fff',
10068 'chatbot_background_color' => $options['chatbot_background_color'] ?? '#212121',
10069 'chatbot_bg_color' => $options['chatbot_bg_color'] ?? '#fff',
10070 'icon_color' => $options['icon_color'] ?? '#fff',
10071 'chat_input_font_color' => $options['chat_input_font_color'] ?? '#212121',
10072 'chat_persistence_toggle' => 'off', // Always off for testing chatbot
10073 'appendWidgetToBody' => 'off',
10074 'live_agent_message_bg_color' => $options['live_agent_message_bg_color'] ?? '#ffffff',
10075 'live_agent_message_font_color' => $options['live_agent_message_font_color'] ?? '#333333',
10076 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
10077 'mode_indicator_bg_color' => $options['mode_indicator_bg_color'] ?? '#767676',
10078 'mode_indicator_font_color' => $options['mode_indicator_font_color'] ?? '#ffffff',
10079 'toolbar_icon_color' => $options['toolbar_icon_color'] ?? '#212121',
10080 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
10081 'email_collection_enabled' => 'off', // No email collection in testing
10082 'initial_email_state' => null,
10083 'skip_email_check' => true,
10084 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
10085 'skip_inline_colors' => $skip_inline_colors,
10086 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
10087 ));
10088
10089 // Localize testing tab script data
10090 wp_localize_script('mxchat-admin-testing-js', 'mxchatAdminTestData', array(
10091 'ajaxUrl' => admin_url('admin-ajax.php'),
10092 'nonce' => wp_create_nonce('mxchat_test_nonce'),
10093 'isAdmin' => true,
10094 'testingEnabled' => true
10095 ));
10096
10097 // Add testing enabled flag for the chatbot to return debug data
10098 add_action('admin_footer', function() {
10099 echo '<script>window.mxchatTestingEnabled = true;</script>';
10100 });
10101
10102 break;
10103 }
10104 }
10105 /**
10106 * The base mxchatAdmin payload shared by every MxChat admin page.
10107 *
10108 * Extracted (plan c192a4) so page-specific re-localizations of the SAME
10109 * global — the knowledge page prints a second `var mxchatAdmin` via the
10110 * mxchat-knowledge-processing handle, and last-printed wins — can merge
10111 * their extra keys on top of this instead of silently narrowing the object
10112 * that mxchat-admin.js also reads on that page.
10113 */
10114 private function mxchat_get_admin_base_localization() {
10115 return array(
10116 'ajax_url' => admin_url('admin-ajax.php'),
10117 'nonce' => wp_create_nonce('mxchat_admin_nonce'),
10118 'admin_url' => admin_url(),
10119 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce'),
10120 'inline_edit_nonce' => wp_create_nonce('mxchat_save_inline_nonce'),
10121 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10122 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
10123 'actions_nonce' => wp_create_nonce('mxchat_actions_nonce'),
10124 'add_intent_nonce' => wp_create_nonce('mxchat_add_intent_nonce'),
10125 'edit_intent_nonce' => wp_create_nonce('mxchat_edit_intent'),
10126 'add_phrase_nonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
10127 'delete_phrase_nonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
10128 'get_phrases_nonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
10129 'delete_legacy_nonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
10130 'toggle_action_nonce' => wp_create_nonce('mxchat_actions_nonce'),
10131 'fetch_openrouter_models_nonce' => wp_create_nonce('mxchat_fetch_openrouter_models'),
10132 'is_activated' => $this->is_activated ? '1' : '0',
10133 'status_refresh_interval' => 5000,
10134 'discard_changes_confirm' => __('Discard your unsaved changes?', 'mxchat'),
10135 'unsaved_label' => __('Unsaved', 'mxchat'),
10136 // Live-agent schedule status text (plan 8ccaa2).
10137 'i18n_scheduled_hours' => __('Scheduled hours', 'mxchat'),
10138 'i18n_always_available' => __('Always available', 'mxchat'),
10139 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10140 'ajaxurl' => admin_url('admin-ajax.php')
10141 );
10142 }
10143 private function localize_admin_scripts($current_page) {
10144 // Base localization data for main admin script
10145 $base_data = $this->mxchat_get_admin_base_localization();
10146
10147 // Localize main admin script with base data
10148 wp_localize_script('mxchat-admin-js', 'mxchatAdmin', $base_data);
10149
10150 // Canonical chat-model catalog for the modal picker grid
10151 // (plan-d14e89). Adding a model in class-mxchat-model-catalog.php
10152 // automatically appears here.
10153 if (!class_exists('MxChat_Model_Catalog')) {
10154 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10155 }
10156 wp_localize_script('mxchat-admin-js', 'mxchatChatModelCatalog', MxChat_Model_Catalog::js_picker_shape());
10157
10158 // Page-specific localizations
10159 $this->localize_page_specific_scripts($current_page);
10160 }
10161 private function localize_page_specific_scripts($current_page) {
10162 switch ($current_page) {
10163 case 'mxchat-prompts':
10164 // Status updater localization
10165 wp_localize_script('mxchat-status-updater', 'mxchat_status_data', array(
10166 'ajax_url' => admin_url('admin-ajax.php'),
10167 'nonce' => wp_create_nonce('mxchat_status_nonce')
10168 ));
10169
10170 // Content selector localization
10171 wp_localize_script('mxchat-content-selector-js', 'mxchatSelector', array(
10172 'ajaxurl' => admin_url('admin-ajax.php'),
10173 'nonce' => wp_create_nonce('mxchat_content_selector_nonce'),
10174 'i18n' => array(
10175 'searchPlaceholder' => __('Search posts and pages...', 'mxchat'),
10176 'selectAll' => __('Select All', 'mxchat'),
10177 'process' => __('Process Selected', 'mxchat'),
10178 'cancel' => __('Cancel', 'mxchat'),
10179 'noResults' => __('No content found.', 'mxchat'),
10180 'extractingPdfs' => __('extracting %d PDF(s)...', 'mxchat'),
10181 'pdfExtractedSuffix' => __(' (%d PDF(s) extracted)', 'mxchat')
10182 )
10183 ));
10184
10185 // This prints a SECOND `var mxchatAdmin` (after mxchat-admin-js's
10186 // base one) and last-printed wins — so it must carry the FULL base
10187 // payload plus the knowledge-page extras, or every mxchatAdmin.*
10188 // key mxchat-admin.js reads on this page comes up undefined
10189 // (plan c192a4). Extras merge last: status_refresh_interval stays
10190 // at this page's faster 2000ms cadence.
10191 wp_localize_script('mxchat-knowledge-processing', 'mxchatAdmin', array_merge(
10192 $this->mxchat_get_admin_base_localization(),
10193 array(
10194 'status_nonce' => wp_create_nonce('mxchat_status_nonce'),
10195 'queue_nonce' => wp_create_nonce('mxchat_queue_nonce'),
10196 'stop_nonce' => wp_create_nonce('mxchat_stop_processing_action'),
10197 'settings_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10198 'entries_nonce' => wp_create_nonce('mxchat_entries_nonce'),
10199 'status_refresh_interval' => 2000,
10200 'bot_id' => isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default'
10201 )
10202 ));
10203 break;
10204
10205 case 'mxchat-activation':
10206 // Same last-printed-wins clobber as the knowledge page (plan c192a4,
10207 // fixed here by a5b297): this second `var mxchatAdmin` must carry
10208 // the full base payload plus the page extras.
10209 wp_localize_script('mxchat-activation-js', 'mxchatAdmin', array_merge(
10210 $this->mxchat_get_admin_base_localization(),
10211 array(
10212 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce')
10213 )
10214 ));
10215 break;
10216
10217 case 'mxchat-content':
10218 wp_localize_script('mxchat-content-js', 'mxchatContent', array(
10219 'ajaxUrl' => admin_url('admin-ajax.php'),
10220 'nonce' => wp_create_nonce('mxchat_content_nonce'),
10221 'settingNonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10222 'previewUrl' => home_url('/?p='),
10223 'isActivated' => $this->is_activated(),
10224 'hasAdvancedContent' => apply_filters('mxchat_content_pro_feature', false, 'seo_readability'),
10225 'hasGSC' => apply_filters('mxchat_content_pro_feature', false, 'gsc_integration'),
10226 // Whether Search Console is actually LINKED — distinct from hasGSC
10227 // (add-on active). Reads the same option the add-on's settings
10228 // card keys off; harmless false when the add-on is absent.
10229 'gscLinked' => (bool) get_option('mxchat_gsc_connected', false),
10230 'seoOptimize' => array(
10231 'meta_description' => ($options['seo_optimize_meta_desc'] ?? 'on') === 'on',
10232 'seo_title' => ($options['seo_optimize_seo_title'] ?? 'on') === 'on',
10233 'slug' => ($options['seo_optimize_slug'] ?? 'on') === 'on',
10234 'readability' => ($options['seo_optimize_readability'] ?? 'on') === 'on',
10235 'internal_links' => ($options['seo_optimize_internal_links'] ?? 'on') === 'on',
10236 'img_alt' => ($options['seo_optimize_img_alt'] ?? 'on') === 'on',
10237 'featured_img' => ($options['seo_optimize_featured_img'] ?? 'on') === 'on',
10238 ),
10239 'i18n' => array(
10240 'generating' => __('Generating...', 'mxchat'),
10241 'planning' => __('Planning content structure...', 'mxchat'),
10242 'images' => __('Generating images...', 'mxchat'),
10243 'writing' => __('Writing full content...', 'mxchat'),
10244 'creating' => __('Creating WordPress post...', 'mxchat'),
10245 'done' => __('Content generated successfully!', 'mxchat'),
10246 'error' => __('An error occurred. Please try again.', 'mxchat'),
10247 'editSuccess' => __('Content updated successfully.', 'mxchat'),
10248 'promptEmpty' => __('Please enter a prompt.', 'mxchat'),
10249 )
10250 ));
10251 break;
10252
10253 case 'mxchat-transcripts':
10254 // Get chart data for localization
10255 $chart_data = $this->get_transcripts_chart_data();
10256
10257 // Same last-printed-wins clobber as the knowledge page (plan c192a4,
10258 // fixed here by a5b297): merge the base payload under the page keys.
10259 // export_nonce/setting_nonce/ajax_url duplicate base values identically;
10260 // delete_nonce and translate_nonce are the page-specific adds.
10261 wp_localize_script('mxchat-transcripts-js', 'mxchatAdmin', array_merge(
10262 $this->mxchat_get_admin_base_localization(),
10263 array(
10264 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
10265 'delete_nonce' => wp_create_nonce('mxchat_delete_chat_history'),
10266 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10267 'translate_nonce' => wp_create_nonce('mxchat_translate_messages')
10268 )
10269 ));
10270
10271 // Localize chart data separately - use array_values to ensure proper JSON array encoding
10272 wp_localize_script('mxchat-transcripts-js', 'mxchatChartData', array(
10273 'labels' => array_values($chart_data['labels']),
10274 'chats' => array_values($chart_data['chats']),
10275 'messages' => array_values($chart_data['messages'])
10276 ));
10277 break;
10278
10279 case 'mxchat-settings':
10280 default:
10281 // The 'mxchatStyleSettings' localize that used to live here was
10282 // REMOVED by plan-mxchat-20260731-c63fb6.
10283 //
10284 // It targeted the script handle 'mxchat-color-picker', which is
10285 // registered/enqueued NOWHERE in the plugin — so wp_localize_script()
10286 // returned false and printed nothing. That accident was the only
10287 // thing keeping it from being a live secret disclosure: the payload
10288 // carried loops_api_key, live_agent_secret_key and
10289 // live_agent_bot_token in full, and this whole method ran for any
10290 // logged-in user (see the capability gate added to
10291 // mxchat_enqueue_admin_assets). One future
10292 // wp_enqueue_script('mxchat-color-picker', ...) would have printed
10293 // three secrets into page HTML for Subscribers.
10294 //
10295 // Nothing consumed the object: a tree-wide grep for
10296 // 'mxchatStyleSettings' returns only this call site, and js/ names
10297 // loops_api_key only at mxchat-admin.js:572, as a field-NAME string
10298 // in an autosave allowlist — not a value read. Removed entirely
10299 // rather than deleting just the three secret keys, so there is no
10300 // dead payload left for someone to re-arm.
10301 break;
10302 }
10303
10304 // Additional localization that was in the original code
10305 wp_localize_script('mxchat-admin-js', 'mxchatPromptsAdmin', array(
10306 'ajax_url' => admin_url('admin-ajax.php'),
10307 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10308 ));
10309 }
10310
10311 public function mxchat_sanitize($input) {
10312 $new_input = array();
10313
10314 if (isset($input['api_key'])) {
10315 $new_input['api_key'] = sanitize_text_field($input['api_key']);
10316 }
10317
10318 if (isset($input['similarity_threshold'])) {
10319 $new_input['similarity_threshold'] = absint($input['similarity_threshold']); // Ensure it's an integer
10320 $new_input['similarity_threshold'] = min(max($new_input['similarity_threshold'], 20), 85); // Enforce range
10321 }
10322
10323 if (isset($input['rag_sources_limit'])) {
10324 $new_input['rag_sources_limit'] = absint($input['rag_sources_limit']); // Ensure it's an integer
10325 $new_input['rag_sources_limit'] = min(max($new_input['rag_sources_limit'], 3), 10); // Enforce range 3-10
10326 }
10327
10328 if (isset($input['rag_chunks_limit'])) {
10329 $new_input['rag_chunks_limit'] = absint($input['rag_chunks_limit']); // Ensure it's an integer
10330 $new_input['rag_chunks_limit'] = min(max($new_input['rag_chunks_limit'], 8), 20); // Enforce range 8-20
10331 }
10332
10333 if (isset($input['xai_api_key'])) {
10334 $new_input['xai_api_key'] = sanitize_text_field($input['xai_api_key']);
10335 }
10336
10337 if (isset($input['claude_api_key'])) {
10338 $new_input['claude_api_key'] = sanitize_text_field($input['claude_api_key']);
10339 }
10340
10341 if (isset($input['enable_streaming_toggle'])) {
10342 $new_input['enable_streaming_toggle'] = ($input['enable_streaming_toggle'] === 'on') ? 'on' : 'off';
10343 } else {
10344 // If checkbox not checked, it won't be in $input, so set to 'off'
10345 $new_input['enable_streaming_toggle'] = 'off';
10346 }
10347
10348 if (isset($input['enable_web_search'])) {
10349 $new_input['enable_web_search'] = ($input['enable_web_search'] === 'on') ? 'on' : 'off';
10350 } else {
10351 // If checkbox not checked, it won't be in $input, so set to 'off'
10352 $new_input['enable_web_search'] = 'off';
10353 }
10354
10355 if (isset($input['deepseek_api_key'])) {
10356 $new_input['deepseek_api_key'] = sanitize_text_field($input['deepseek_api_key']);
10357 }
10358
10359 if (isset($input['gemini_api_key'])) {
10360 $new_input['gemini_api_key'] = sanitize_text_field($input['gemini_api_key']);
10361 }
10362
10363 if (isset($input['enable_woocommerce_integration'])) {
10364 $new_input['enable_woocommerce_integration'] = $input['enable_woocommerce_integration'] === 'on' ? 'on' : 'off';
10365 }
10366
10367 if (isset($input['privacy_toggle'])) {
10368 $new_input['privacy_toggle'] = $input['privacy_toggle'];
10369 }
10370
10371 if (isset($input['complianz_toggle'])) {
10372 $new_input['complianz_toggle'] = $input['complianz_toggle'];
10373 }
10374
10375 // Handle custom privacy text input
10376 if (isset($input['privacy_text'])) {
10377 // Allow basic HTML for links
10378 $new_input['privacy_text'] = wp_kses_post($input['privacy_text']);
10379 }
10380
10381 if (isset($input['system_prompt_instructions'])) {
10382 $new_input['system_prompt_instructions'] = sanitize_textarea_field($input['system_prompt_instructions']);
10383 }
10384
10385 if (isset($input['mxchat_pro_email'])) {
10386 $new_input['mxchat_pro_email'] = sanitize_email($input['mxchat_pro_email']);
10387 }
10388
10389 if (isset($input['mxchat_activation_key'])) {
10390 $new_input['mxchat_activation_key'] = sanitize_text_field($input['mxchat_activation_key']);
10391 }
10392
10393 if (isset($input['append_to_body'])) {
10394 $new_input['append_to_body'] = $input['append_to_body'] === 'on' ? 'on' : 'off';
10395 }
10396
10397 // Post type visibility settings
10398 if (isset($input['post_type_visibility_mode'])) {
10399 $allowed_modes = array('all', 'include', 'exclude');
10400 $new_input['post_type_visibility_mode'] = in_array($input['post_type_visibility_mode'], $allowed_modes)
10401 ? $input['post_type_visibility_mode']
10402 : 'all';
10403 }
10404
10405 if (isset($input['post_type_visibility_list'])) {
10406 if (is_array($input['post_type_visibility_list'])) {
10407 $new_input['post_type_visibility_list'] = array_map('sanitize_key', $input['post_type_visibility_list']);
10408 } else {
10409 $new_input['post_type_visibility_list'] = array();
10410 }
10411 }
10412
10413 if (isset($input['contextual_awareness_toggle'])) {
10414 $new_input['contextual_awareness_toggle'] = $input['contextual_awareness_toggle'] === 'on' ? 'on' : 'off';
10415 }
10416
10417 if (isset($input['citation_links_toggle'])) {
10418 $new_input['citation_links_toggle'] = $input['citation_links_toggle'] === 'on' ? 'on' : 'off';
10419 }
10420
10421 if (isset($input['strip_unapproved_links_toggle'])) {
10422 $new_input['strip_unapproved_links_toggle'] = $input['strip_unapproved_links_toggle'] === 'on' ? 'on' : 'off';
10423 }
10424
10425 // Satisfaction rating prompt — defaults ON when unchecked (so first save
10426 // doesn't accidentally disable it). The form posts 'on' when checked;
10427 // unchecked checkboxes don't post a value at all, so we infer 'off' only
10428 // when the autosave/PHP request explicitly clears it via empty string.
10429 if (array_key_exists('satisfaction_rating_enabled', $input)) {
10430 $new_input['satisfaction_rating_enabled'] = $input['satisfaction_rating_enabled'] === 'on' ? 'on' : 'off';
10431 }
10432
10433 // Satisfaction rating customization (plan-141a12). Idle is clamped 5-600;
10434 // the 4 text strings are sanitized + capped server-side. Blank values are
10435 // preserved so the integrator falls back to translated defaults.
10436 if (array_key_exists('satisfaction_rating_idle_seconds', $input)) {
10437 $new_input['satisfaction_rating_idle_seconds'] = max(5, min(600, intval($input['satisfaction_rating_idle_seconds'])));
10438 }
10439
10440 // Max input length in characters (plan a3fae2 part C). 0 = unlimited (default,
10441 // preserves current behavior). Clamp to a sane ceiling so a typo can't lock out
10442 // all input. REQUIRED here — mxchat_sanitize() rebuilds the array from a whitelist,
10443 // so without this isset-handler the key is stripped on the next save of ANY field
10444 // (the 2c02ea global-rate-limit footgun).
10445 if (array_key_exists('max_input_length', $input)) {
10446 $new_input['max_input_length'] = max(0, min(100000, intval($input['max_input_length'])));
10447 }
10448 if (array_key_exists('satisfaction_rating_question', $input)) {
10449 $new_input['satisfaction_rating_question'] = mb_substr(sanitize_text_field($input['satisfaction_rating_question']), 0, 200);
10450 }
10451 if (array_key_exists('satisfaction_rating_thanks', $input)) {
10452 $new_input['satisfaction_rating_thanks'] = mb_substr(sanitize_text_field($input['satisfaction_rating_thanks']), 0, 300);
10453 }
10454 if (array_key_exists('satisfaction_rating_placeholder', $input)) {
10455 $new_input['satisfaction_rating_placeholder'] = mb_substr(sanitize_text_field($input['satisfaction_rating_placeholder']), 0, 200);
10456 }
10457 if (array_key_exists('satisfaction_rating_saved', $input)) {
10458 $new_input['satisfaction_rating_saved'] = mb_substr(sanitize_text_field($input['satisfaction_rating_saved']), 0, 200);
10459 }
10460
10461 if (isset($input['top_bar_title'])) {
10462 $new_input['top_bar_title'] = sanitize_text_field($input['top_bar_title']);
10463 }
10464
10465 if (isset($input['ai_agent_text'])) {
10466 $new_input['ai_agent_text'] = sanitize_text_field($input['ai_agent_text']);
10467 }
10468
10469 if (isset($input['enable_email_block'])) {
10470 $new_input['enable_email_block'] = sanitize_text_field($input['enable_email_block']);
10471 }
10472
10473 if (isset($input['email_blocker_header_content'])) {
10474 // wp_kses_post() allows standard HTML tags permitted by WordPress
10475 $new_input['email_blocker_header_content'] = wp_kses_post($input['email_blocker_header_content']);
10476 }
10477 if (isset($input['email_blocker_button_text'])) {
10478 $new_input['email_blocker_button_text'] = sanitize_text_field($input['email_blocker_button_text']);
10479 }
10480 // Sanitize name field toggle
10481 if (isset($input['enable_name_field'])) {
10482 $new_input['enable_name_field'] = ($input['enable_name_field'] === 'on') ? 'on' : 'off';
10483 } else {
10484 $new_input['enable_name_field'] = 'off';
10485 }
10486 // Sanitize name field placeholder
10487 if (isset($input['name_field_placeholder'])) {
10488 $new_input['name_field_placeholder'] = sanitize_text_field($input['name_field_placeholder']);
10489 }
10490 // Consent checkbox (b062c4). Both toggles default off — an unchecked box
10491 // posts nothing, so the else-branch is what makes unticking stick.
10492 if (isset($input['enable_consent_checkbox'])) {
10493 $new_input['enable_consent_checkbox'] = ($input['enable_consent_checkbox'] === 'on') ? 'on' : 'off';
10494 } else {
10495 $new_input['enable_consent_checkbox'] = 'off';
10496 }
10497 if (isset($input['consent_checkbox_label'])) {
10498 // Shared allowlist with the render path — see MxChat_Utils::sanitize_consent_label().
10499 $new_input['consent_checkbox_label'] = MxChat_Utils::sanitize_consent_label($input['consent_checkbox_label']);
10500 }
10501 if (isset($input['consent_checkbox_required'])) {
10502 $new_input['consent_checkbox_required'] = ($input['consent_checkbox_required'] === 'on') ? 'on' : 'off';
10503 } else {
10504 $new_input['consent_checkbox_required'] = 'off';
10505 }
10506 if (isset($input['intro_message'])) {
10507 $new_input['intro_message'] = wp_kses_post($input['intro_message']); // Use wp_kses_post instead
10508 }
10509
10510 if (isset($input['input_copy'])) {
10511 $new_input['input_copy'] = sanitize_text_field($input['input_copy']);
10512 }
10513
10514 if (isset($input['rate_limit_message'])) {
10515 $new_input['rate_limit_message'] = sanitize_text_field($input['rate_limit_message']);
10516 }
10517
10518 // Handle the new rate limits format
10519 if (isset($input['rate_limits']) && is_array($input['rate_limits'])) {
10520 $new_input['rate_limits'] = array();
10521 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
10522 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
10523
10524 foreach ($input['rate_limits'] as $role_id => $settings) {
10525 $new_input['rate_limits'][$role_id] = array();
10526
10527 // Sanitize limit
10528 if (isset($settings['limit'])) {
10529 $limit = sanitize_text_field($settings['limit']);
10530 // Accept presets, 'unlimited', the '__custom__' sentinel, OR any positive
10531 // integer (custom value) — mirrors the global branch (plan-2c02ea). Without
10532 // the custom path the per-role custom input was dropped and reset to the role
10533 // default on every save (plan-7e23e7).
10534 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
10535 $new_input['rate_limits'][$role_id]['limit'] = $limit;
10536 } else {
10537 $new_input['rate_limits'][$role_id]['limit'] = ($role_id === 'logged_out') ? '10' : '100'; // Default
10538 }
10539 }
10540
10541 // Preserve the per-role custom value (mirrors the global branch's limit_custom).
10542 if (isset($settings['limit_custom'])) {
10543 $new_input['rate_limits'][$role_id]['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $settings['limit_custom']);
10544 }
10545
10546 // Sanitize timeframe
10547 if (isset($settings['timeframe'])) {
10548 $timeframe = sanitize_text_field($settings['timeframe']);
10549 if (in_array($timeframe, $allowed_timeframes, true)) {
10550 $new_input['rate_limits'][$role_id]['timeframe'] = $timeframe;
10551 } else {
10552 $new_input['rate_limits'][$role_id]['timeframe'] = 'daily'; // Default
10553 }
10554 }
10555
10556 // Sanitize message
10557 if (isset($settings['message'])) {
10558 $new_input['rate_limits'][$role_id]['message'] = sanitize_textarea_field($settings['message']);
10559 }
10560 }
10561 }
10562
10563 // Handle the whole-chatbot global rate limit (plan-mxchat-20260603-2c02ea).
10564 // Mirrors the per-role block above, adapted to the single global shape. Without
10565 // this branch the whitelist-rebuild dropped rate_limits_global entirely on every
10566 // save, so the global cap silently fell back to its 'unlimited' default.
10567 // MUST accept arbitrary positive integers so the custom-value path (d55f65) is not regressed.
10568 if (isset($input['rate_limits_global']) && is_array($input['rate_limits_global'])) {
10569 $g = $input['rate_limits_global'];
10570 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
10571 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
10572 $global_out = array();
10573
10574 if (isset($g['limit'])) {
10575 $limit = sanitize_text_field($g['limit']);
10576 // Accept presets, 'unlimited', the '__custom__' sentinel (resolved by the
10577 // autosave handler / renderer), OR any positive integer (custom value).
10578 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
10579 $global_out['limit'] = $limit;
10580 } else {
10581 $global_out['limit'] = 'unlimited';
10582 }
10583 }
10584
10585 if (isset($g['limit_custom'])) {
10586 $global_out['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $g['limit_custom']);
10587 }
10588
10589 if (isset($g['timeframe'])) {
10590 $timeframe = sanitize_text_field($g['timeframe']);
10591 $global_out['timeframe'] = in_array($timeframe, $allowed_timeframes, true) ? $timeframe : 'daily';
10592 }
10593
10594 if (isset($g['message'])) {
10595 $global_out['message'] = sanitize_textarea_field($g['message']);
10596 }
10597
10598 $new_input['rate_limits_global'] = $global_out;
10599 }
10600
10601 if (isset($input['pre_chat_message'])) {
10602 $new_input['pre_chat_message'] = sanitize_textarea_field($input['pre_chat_message']);
10603 }
10604
10605 if (isset($input['voyage_api_key'])) {
10606 $new_input['voyage_api_key'] = sanitize_text_field($input['voyage_api_key']);
10607 }
10608
10609 // Add to your sanitize function
10610 if (isset($input['embedding_model'])) {
10611 // Catalog refactor (plan-d14e89): allowlist derived from the canonical
10612 // catalog in includes/class-mxchat-model-catalog.php.
10613 if (!class_exists('MxChat_Model_Catalog')) {
10614 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10615 }
10616 $allowed_models = MxChat_Model_Catalog::embedding_model_ids();
10617 if (in_array($input['embedding_model'], $allowed_models)) {
10618 $new_input['embedding_model'] = sanitize_text_field($input['embedding_model']);
10619 }
10620 }
10621
10622 if (isset($input['model'])) {
10623 if ($input['model'] === 'openrouter') {
10624 $new_input['model'] = 'openrouter';
10625 } else {
10626 if (!class_exists('MxChat_Model_Catalog')) {
10627 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10628 }
10629 $allowed_models = MxChat_Model_Catalog::chat_model_ids();
10630
10631 if (in_array($input['model'], $allowed_models)) {
10632 $new_input['model'] = sanitize_text_field($input['model']);
10633 } else {
10634 // Fallback for any deprecated model
10635 $new_input['model'] = 'gpt-5.6-sol';
10636 }
10637 }
10638 }
10639
10640 if (isset($input['openrouter_selected_model'])) {
10641 // Just sanitize it, don't validate against a whitelist
10642 $new_input['openrouter_selected_model'] = sanitize_text_field($input['openrouter_selected_model']);
10643 }
10644
10645 // ADD THIS:
10646 if (isset($input['openrouter_selected_model_name'])) {
10647 $new_input['openrouter_selected_model_name'] = sanitize_text_field($input['openrouter_selected_model_name']);
10648 }
10649
10650 if (isset($input['openrouter_api_key'])) {
10651 $new_input['openrouter_api_key'] = sanitize_text_field($input['openrouter_api_key']);
10652 }
10653
10654 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, Azure OpenAI, etc.
10655 if (isset($input['custom_provider_base_url'])) {
10656 $new_input['custom_provider_base_url'] = esc_url_raw(rtrim(trim((string) $input['custom_provider_base_url']), '/'));
10657 }
10658 if (isset($input['custom_provider_api_key'])) {
10659 $new_input['custom_provider_api_key'] = sanitize_text_field($input['custom_provider_api_key']);
10660 }
10661 if (isset($input['custom_provider_model'])) {
10662 $new_input['custom_provider_model'] = sanitize_text_field($input['custom_provider_model']);
10663 }
10664 if (isset($input['custom_provider_auth_scheme'])) {
10665 $scheme = sanitize_text_field($input['custom_provider_auth_scheme']);
10666 $new_input['custom_provider_auth_scheme'] = in_array($scheme, array('bearer', 'api-key'), true) ? $scheme : 'bearer';
10667 }
10668 if (isset($input['custom_provider_for_embeddings'])) {
10669 $new_input['custom_provider_for_embeddings'] = ($input['custom_provider_for_embeddings'] === 'on') ? 'on' : 'off';
10670 }
10671 if (isset($input['custom_provider_for_images'])) {
10672 $new_input['custom_provider_for_images'] = ($input['custom_provider_for_images'] === 'on') ? 'on' : 'off';
10673 }
10674 if (isset($input['custom_provider_embedding_model'])) {
10675 $new_input['custom_provider_embedding_model'] = sanitize_text_field($input['custom_provider_embedding_model']);
10676 }
10677 if (isset($input['custom_provider_api_version'])) {
10678 $new_input['custom_provider_api_version'] = sanitize_text_field($input['custom_provider_api_version']);
10679 }
10680
10681
10682
10683 if (isset($input['woocommerce_consumer_key'])) {
10684 $new_input['woocommerce_consumer_key'] = sanitize_text_field($input['woocommerce_consumer_key']);
10685 }
10686
10687 if (isset($input['woocommerce_consumer_secret'])) {
10688 $new_input['woocommerce_consumer_secret'] = sanitize_text_field($input['woocommerce_consumer_secret']);
10689 }
10690
10691
10692 // Sanitize link_target_toggle
10693 if (isset($input['link_target_toggle'])) {
10694 $new_input['link_target_toggle'] = $input['link_target_toggle'] === 'on' ? 'on' : 'off';
10695 }
10696
10697 // Sanitize Loops API Key
10698 if (isset($input['loops_api_key'])) {
10699 $new_input['loops_api_key'] = sanitize_text_field($input['loops_api_key']);
10700 }
10701
10702 if (isset($input['chat_persistence_toggle'])) {
10703 $new_input['chat_persistence_toggle'] = $input['chat_persistence_toggle'] === 'on' ? 'on' : 'off';
10704 }
10705
10706 // No else clause: an absent key stays absent, so the front-end default ('on') applies
10707 // and the rebuild never strips a saved 'off' (autosave passes the full options array back through here).
10708 if (isset($input['print_button_enabled'])) {
10709 $new_input['print_button_enabled'] = $input['print_button_enabled'] === 'on' ? 'on' : 'off';
10710 }
10711
10712 // plan ac2e81 — "Start new chat" toggle (default OFF) + editable label.
10713 // No else on the toggle: an absent key stays absent so the front-end '?? off'
10714 // default applies, and the full-array autosave rebuild preserves a saved value.
10715 if (isset($input['reset_chat_enabled'])) {
10716 $new_input['reset_chat_enabled'] = $input['reset_chat_enabled'] === 'on' ? 'on' : 'off';
10717 }
10718
10719 if (isset($input['reset_chat_label'])) {
10720 $new_input['reset_chat_label'] = sanitize_text_field($input['reset_chat_label']);
10721 }
10722
10723 if (isset($input['popular_question_1'])) {
10724 $new_input['popular_question_1'] = sanitize_text_field($input['popular_question_1']);
10725 }
10726
10727 if (isset($input['popular_question_2'])) {
10728 $new_input['popular_question_2'] = sanitize_text_field($input['popular_question_2']);
10729 }
10730
10731 if (isset($input['popular_question_3'])) {
10732 $new_input['popular_question_3'] = sanitize_text_field($input['popular_question_3']);
10733 }
10734
10735 if (isset($input['additional_popular_questions']) && is_array($input['additional_popular_questions'])) {
10736 $new_input['additional_popular_questions'] = array_map('sanitize_text_field', $input['additional_popular_questions']);
10737 }
10738
10739 // Sanitize Loops Mailing List
10740 if (isset($input['loops_mailing_list'])) {
10741 $new_input['loops_mailing_list'] = sanitize_text_field($input['loops_mailing_list']);
10742 }
10743
10744 // Sanitize Triggered Phrase Response
10745 if (isset($input['triggered_phrase_response'])) {
10746 $new_input['triggered_phrase_response'] = wp_kses_post($input['triggered_phrase_response']);
10747 }
10748 if (isset($input['email_capture_response'])) {
10749 $new_input['email_capture_response'] = wp_kses_post($input['email_capture_response']);
10750 }
10751
10752 // Sanitize Brave Search Settings
10753 if (isset($input['brave_api_key'])) {
10754 $new_input['brave_api_key'] = sanitize_text_field($input['brave_api_key']);
10755 }
10756
10757 if (isset($input['brave_image_count'])) {
10758 $image_count = intval($input['brave_image_count']);
10759 $new_input['brave_image_count'] = ($image_count >=1 && $image_count <=6) ? $image_count : 4;
10760 }
10761
10762 if (isset($input['brave_safe_search'])) {
10763 $allowed = array('strict', 'off');
10764 $new_input['brave_safe_search'] = in_array($input['brave_safe_search'], $allowed, true) ? $input['brave_safe_search'] : 'strict';
10765 }
10766
10767 if (isset($input['brave_news_count'])) {
10768 $news_count = intval($input['brave_news_count']);
10769 $new_input['brave_news_count'] = ($news_count >=1 && $news_count <=10) ? $news_count : 3;
10770 }
10771
10772 if (isset($input['brave_country'])) {
10773 $new_input['brave_country'] = sanitize_text_field($input['brave_country']);
10774 }
10775
10776 if (isset($input['brave_language'])) {
10777 $new_input['brave_language'] = sanitize_text_field($input['brave_language']);
10778 }
10779
10780 if (isset($input['chat_toolbar_toggle'])) {
10781 $new_input['chat_toolbar_toggle'] = $input['chat_toolbar_toggle'] === 'on' ? 'on' : 'off';
10782 }
10783
10784 // Sanitize PDF upload button toggle
10785 if (isset($input['show_pdf_upload_button'])) {
10786 $new_input['show_pdf_upload_button'] = $input['show_pdf_upload_button'] === 'on' ? 'on' : 'off';
10787 } else {
10788 $new_input['show_pdf_upload_button'] = 'off'; // If checkbox is unchecked
10789 }
10790
10791 // Sanitize Word upload button toggle
10792 if (isset($input['show_word_upload_button'])) {
10793 $new_input['show_word_upload_button'] = $input['show_word_upload_button'] === 'on' ? 'on' : 'off';
10794 } else {
10795 $new_input['show_word_upload_button'] = 'off'; // If checkbox is unchecked
10796 }
10797
10798 if (isset($input['pdf_intent_trigger_text'])) {
10799 $new_input['pdf_intent_trigger_text'] = sanitize_text_field($input['pdf_intent_trigger_text']);
10800 }
10801
10802 if (isset($input['pdf_intent_success_text'])) {
10803 $new_input['pdf_intent_success_text'] = sanitize_text_field($input['pdf_intent_success_text']);
10804 }
10805
10806 if (isset($input['pdf_intent_error_text'])) {
10807 $new_input['pdf_intent_error_text'] = sanitize_text_field($input['pdf_intent_error_text']);
10808 }
10809
10810 if (isset($input['pdf_max_pages'])) {
10811 $new_input['pdf_max_pages'] = intval($input['pdf_max_pages']);
10812 if ($new_input['pdf_max_pages'] < 1 || $new_input['pdf_max_pages'] > 69) {
10813 $new_input['pdf_max_pages'] = 69; // Default to 69 if out of range
10814 }
10815 }
10816
10817 if (isset($input['live_agent_webhook_url'])) {
10818 $new_input['live_agent_webhook_url'] = esc_url_raw($input['live_agent_webhook_url']);
10819 }
10820 if (isset($input['live_agent_secret_key'])) {
10821 $new_input['live_agent_secret_key'] = sanitize_text_field($input['live_agent_secret_key']);
10822 }
10823
10824 // Live Agent Integration
10825 if (isset($input['live_agent_bot_token'])) {
10826 $new_input['live_agent_bot_token'] = sanitize_text_field($input['live_agent_bot_token']);
10827 }
10828
10829 if (isset($input['live_agent_shared_channel'])) {
10830 $new_input['live_agent_shared_channel'] = sanitize_text_field($input['live_agent_shared_channel']);
10831 }
10832
10833 // Default-OFF toggle: absent key stays absent (unchecked box = off).
10834 if (isset($input['live_agent_archive_on_end_toggle'])) {
10835 $new_input['live_agent_archive_on_end_toggle'] = ($input['live_agent_archive_on_end_toggle'] === 'on') ? 'on' : 'off';
10836 }
10837
10838 if (isset($input['live_agent_user_ids'])) {
10839 $new_input['live_agent_user_ids'] = sanitize_textarea_field($input['live_agent_user_ids']);
10840 }
10841
10842 if (isset($input['live_agent_status'])) {
10843 $new_input['live_agent_status'] = ($input['live_agent_status'] === 'on') ? 'on' : 'off';
10844 }
10845 if (isset($input['live_agent_away_message'])) {
10846 $new_input['live_agent_away_message'] = sanitize_textarea_field($input['live_agent_away_message']);
10847 }
10848 if (isset($input['live_agent_notification_message'])) {
10849 $new_input['live_agent_notification_message'] = sanitize_textarea_field($input['live_agent_notification_message']);
10850 }
10851
10852 // Telegram Integration
10853 if (isset($input['telegram_status'])) {
10854 $new_input['telegram_status'] = ($input['telegram_status'] === 'on') ? 'on' : 'off';
10855 }
10856 if (isset($input['telegram_bot_token'])) {
10857 $new_input['telegram_bot_token'] = sanitize_text_field($input['telegram_bot_token']);
10858 }
10859 if (isset($input['telegram_group_id'])) {
10860 $new_input['telegram_group_id'] = sanitize_text_field($input['telegram_group_id']);
10861 }
10862 if (isset($input['telegram_webhook_secret'])) {
10863 $new_input['telegram_webhook_secret'] = sanitize_text_field($input['telegram_webhook_secret']);
10864 }
10865 if (isset($input['telegram_notification_message'])) {
10866 $new_input['telegram_notification_message'] = sanitize_textarea_field($input['telegram_notification_message']);
10867 }
10868 if (isset($input['telegram_away_message'])) {
10869 $new_input['telegram_away_message'] = sanitize_textarea_field($input['telegram_away_message']);
10870 }
10871
10872 // Webhook Handoff (plan d88e22)
10873 if (isset($input['webhook_handoff_status'])) {
10874 $new_input['webhook_handoff_status'] = ($input['webhook_handoff_status'] === 'on') ? 'on' : 'off';
10875 }
10876 if (isset($input['webhook_handoff_url'])) {
10877 // https only — esc_url_raw with an https-only protocol list stores ''
10878 // for anything else, so a non-https URL can never persist.
10879 $new_input['webhook_handoff_url'] = esc_url_raw(trim((string) $input['webhook_handoff_url']), array('https'));
10880 }
10881 if (isset($input['webhook_handoff_secret'])) {
10882 $new_input['webhook_handoff_secret'] = sanitize_text_field($input['webhook_handoff_secret']);
10883 }
10884 if (isset($input['webhook_handoff_notification_message'])) {
10885 $new_input['webhook_handoff_notification_message'] = sanitize_textarea_field($input['webhook_handoff_notification_message']);
10886 }
10887 if (isset($input['webhook_handoff_away_message'])) {
10888 $new_input['webhook_handoff_away_message'] = sanitize_textarea_field($input['webhook_handoff_away_message']);
10889 }
10890
10891 // Sanitize script loading strategy
10892 if (isset($input['script_loading_strategy'])) {
10893 $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction');
10894 $new_input['script_loading_strategy'] = in_array($input['script_loading_strategy'], $allowed_strategies)
10895 ? $input['script_loading_strategy']
10896 : 'default';
10897 }
10898
10899 // Sanitize debug mode
10900 if (isset($input['debug_mode'])) {
10901 $new_input['debug_mode'] = ($input['debug_mode'] === 'on') ? 'on' : 'off';
10902 }
10903
10904 // Content Generator Settings
10905 if (isset($input['content_model'])) {
10906 $new_input['content_model'] = sanitize_text_field($input['content_model']);
10907 }
10908 if (isset($input['content_image_model'])) {
10909 $new_input['content_image_model'] = sanitize_text_field($input['content_image_model']);
10910 }
10911 if (isset($input['content_image_quality'])) {
10912 $q = sanitize_text_field($input['content_image_quality']);
10913 $new_input['content_image_quality'] = in_array($q, array('auto', 'low', 'medium', 'high'), true) ? $q : 'auto';
10914 }
10915 if (isset($input['content_enable_images'])) {
10916 $new_input['content_enable_images'] = ($input['content_enable_images'] === 'on') ? 'on' : 'off';
10917 }
10918 if (isset($input['content_use_placeholders'])) {
10919 $new_input['content_use_placeholders'] = ($input['content_use_placeholders'] === 'on') ? 'on' : 'off';
10920 }
10921 if (isset($input['content_internal_linking'])) {
10922 $new_input['content_internal_linking'] = ($input['content_internal_linking'] === 'on') ? 'on' : 'off';
10923 }
10924 if (isset($input['content_tool_use'])) {
10925 $new_input['content_tool_use'] = ($input['content_tool_use'] === 'on') ? 'on' : 'off';
10926 }
10927 if (isset($input['content_image_count'])) {
10928 $new_input['content_image_count'] = (string) max(1, min(5, (int) $input['content_image_count']));
10929 }
10930
10931 // SEO Optimize toggle fields
10932 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) {
10933 if (isset($input[$seo_key])) {
10934 $new_input[$seo_key] = ($input[$seo_key] === 'on') ? 'on' : 'off';
10935 }
10936 }
10937
10938 // Preserve content generator settings when saving from main settings page
10939 // (where content fields are not in the form submission)
10940 $existing = get_option('mxchat_options', array());
10941 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) {
10942 if (!isset($new_input[$key]) && isset($existing[$key])) {
10943 $new_input[$key] = $existing[$key];
10944 }
10945 }
10946
10947 return $new_input;
10948 }
10949
10950 /**
10951 * Log a debug message if debug mode is enabled
10952 *
10953 * @param string $type The type of log entry (settings_save, api_error, activation, etc.)
10954 * @param string $message The log message
10955 * @param array $data Optional additional data to log
10956 * @return bool Whether the message was logged
10957 */
10958 public static function mxchat_log_debug( $type, $message, $data = array() ) {
10959 $options = get_option( 'mxchat_options', array() );
10960
10961 // Check if debug mode is enabled
10962 if ( ! isset( $options['debug_mode'] ) || $options['debug_mode'] !== 'on' ) {
10963 return false;
10964 }
10965
10966 // Get current log
10967 $log = get_option( 'mxchat_debug_log', array() );
10968 if ( ! is_array( $log ) ) {
10969 $log = array();
10970 }
10971
10972 // Add new entry
10973 $entry = array(
10974 'time' => current_time( 'Y-m-d H:i:s' ),
10975 'type' => sanitize_key( $type ),
10976 'message' => sanitize_text_field( $message ),
10977 );
10978
10979 if ( ! empty( $data ) ) {
10980 $entry['data'] = $data;
10981 }
10982
10983 // Add to beginning of array (newest first)
10984 array_unshift( $log, $entry );
10985
10986 // Keep only last 100 entries
10987 if ( count( $log ) > 100 ) {
10988 $log = array_slice( $log, 0, 100 );
10989 }
10990
10991 // Save log
10992 update_option( 'mxchat_debug_log', $log, false );
10993
10994 return true;
10995 }
10996
10997 /**
10998 * Get the debug log entries
10999 *
11000 * @return array The debug log entries
11001 */
11002 public static function mxchat_get_debug_log() {
11003 $log = get_option( 'mxchat_debug_log', array() );
11004 return is_array( $log ) ? $log : array();
11005 }
11006
11007 /**
11008 * Clear the debug log
11009 *
11010 * @return bool Whether the log was cleared
11011 */
11012 public static function mxchat_clear_debug_log() {
11013 return delete_option( 'mxchat_debug_log' );
11014 }
11015
11016 /**
11017 * Export settings as JSON with masked API keys
11018 *
11019 * @return array The sanitized settings array
11020 */
11021 public static function mxchat_export_settings() {
11022 $options = get_option( 'mxchat_options', array() );
11023
11024 if ( ! is_array( $options ) ) {
11025 return array();
11026 }
11027
11028 // List of API key fields to mask
11029 $api_key_fields = array(
11030 'api_key',
11031 'xai_api_key',
11032 'claude_api_key',
11033 'deepseek_api_key',
11034 'voyage_api_key',
11035 'gemini_api_key',
11036 'openrouter_api_key',
11037 'loops_api_key',
11038 'brave_api_key',
11039 'live_agent_secret_key',
11040 'live_agent_bot_token',
11041 'telegram_bot_token',
11042 'telegram_webhook_secret',
11043 'woocommerce_consumer_key',
11044 'woocommerce_consumer_secret',
11045 );
11046
11047 // Mask API keys (show only last 4 characters)
11048 foreach ( $api_key_fields as $field ) {
11049 if ( isset( $options[ $field ] ) && ! empty( $options[ $field ] ) ) {
11050 $value = $options[ $field ];
11051 if ( strlen( $value ) > 4 ) {
11052 $options[ $field ] = str_repeat( '*', strlen( $value ) - 4 ) . substr( $value, -4 );
11053 } else {
11054 $options[ $field ] = '****';
11055 }
11056 }
11057 }
11058
11059 // Add metadata
11060 $export = array(
11061 'plugin_version' => defined( 'MXCHAT_VERSION' ) ? MXCHAT_VERSION : 'unknown',
11062 'export_date' => current_time( 'Y-m-d H:i:s' ),
11063 'wordpress_version' => get_bloginfo( 'version' ),
11064 'php_version' => phpversion(),
11065 'settings' => $options,
11066 );
11067
11068 return $export;
11069 }
11070
11071 /**
11072 * Reset all settings to defaults
11073 *
11074 * @return bool Whether the reset was successful
11075 */
11076 public static function mxchat_reset_all_settings() {
11077 // Delete main options
11078 $deleted = delete_option( 'mxchat_options' );
11079
11080 // Also clear the debug log
11081 delete_option( 'mxchat_debug_log' );
11082
11083 // Log the reset (will create new log since we just cleared it)
11084 // We need to temporarily enable debug mode to log this
11085 $temp_options = array( 'debug_mode' => 'on' );
11086 update_option( 'mxchat_options', $temp_options );
11087
11088 self::mxchat_log_debug( 'reset', 'All settings have been reset to defaults' );
11089
11090 // Now delete again to trigger re-initialization
11091 delete_option( 'mxchat_options' );
11092
11093 return $deleted;
11094 }
11095
11096 // Method to append the chatbot to the body
11097 public function mxchat_append_chatbot_to_body() {
11098 $options = get_option('mxchat_options');
11099 if (isset($options['append_to_body']) && $options['append_to_body'] === 'on') {
11100 echo do_shortcode('[mxchat_chatbot floating="yes"]');
11101 }
11102 }
11103
11104
11105
11106
11107
11108 private function mxchat_fetch_loops_mailing_lists($api_key) {
11109 $url = 'https://app.loops.so/api/v1/lists';
11110 $response = wp_remote_get($url, array(
11111 'headers' => array(
11112 'Authorization' => 'Bearer ' . $api_key,
11113 'Content-Type' => 'application/json'
11114 )
11115 ));
11116
11117 if (is_wp_error($response)) {
11118 return array();
11119 }
11120
11121 $body = wp_remote_retrieve_body($response);
11122 $lists = json_decode($body, true);
11123
11124 return isset($lists) && is_array($lists) ? $lists : array();
11125 }
11126
11127 function mxchat_calculate_cosine_similarity($vec1, $vec2) {
11128 if (empty($vec1) || empty($vec2)) {
11129 return 0.0;
11130 }
11131
11132 $dot_product = 0.0;
11133 $norm_a = 0.0;
11134 $norm_b = 0.0;
11135
11136 for ($i = 0; $i < count($vec1); $i++) {
11137 $dot_product += $vec1[$i] * $vec2[$i];
11138 $norm_a += pow($vec1[$i], 2);
11139 $norm_b += pow($vec2[$i], 2);
11140 }
11141
11142 if ($norm_a == 0.0 || $norm_b == 0.0) {
11143 return 0.0;
11144 } else {
11145 return $dot_product / (sqrt($norm_a) * sqrt($norm_b));
11146 }
11147 }
11148
11149 /**
11150 * Validates nonce and deletes all chat prompts
11151 */
11152 public function mxchat_handle_delete_all_prompts() {
11153 //error_log('=== DELETE ALL DEBUG START ===');
11154
11155 // Verify nonce
11156 if (!isset($_POST['mxchat_delete_all_prompts_nonce']) || !wp_verify_nonce($_POST['mxchat_delete_all_prompts_nonce'], 'mxchat_delete_all_prompts_action')) {
11157 //error_log('DEBUG: Nonce verification failed');
11158 wp_die(__('Nonce verification failed.', 'mxchat'));
11159 }
11160
11161 // Check permissions
11162 if (!current_user_can('manage_options')) {
11163 //error_log('DEBUG: Permission check failed');
11164 wp_die(__('You do not have sufficient permissions to delete all prompts.', 'mxchat'));
11165 }
11166
11167 // Get bot_id and content type filter from POST data
11168 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
11169 $content_type_filter = isset($_POST['content_type_filter']) ? sanitize_text_field($_POST['content_type_filter']) : '';
11170
11171 $success = true;
11172 $error_messages = array();
11173
11174 // Get bot-specific Pinecone configuration
11175 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
11176 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
11177
11178 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
11179
11180 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
11181 // Delete from Pinecone (with optional content type filter)
11182 $result = $pinecone_manager->mxchat_delete_all_from_pinecone($pinecone_options, $content_type_filter);
11183
11184 if (!$result['success']) {
11185 $success = false;
11186 $error_messages[] = $result['message'];
11187 }
11188
11189 } else {
11190 // Delete from WordPress database
11191 global $wpdb;
11192 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
11193
11194 // Build WHERE conditions
11195 $where_clauses = array();
11196 $where_values = array();
11197
11198 // Bot filter
11199 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
11200 $where_clauses[] = 'bot_id = %s';
11201 $where_values[] = $bot_id;
11202 }
11203
11204 // Content type filter
11205 if (!empty($content_type_filter)) {
11206 $where_clauses[] = 'content_type = %s';
11207 $where_values[] = $content_type_filter;
11208 }
11209
11210 if (!empty($where_clauses)) {
11211 $where_sql = implode(' AND ', $where_clauses);
11212 $result = $wpdb->query($wpdb->prepare("DELETE FROM {$table_name} WHERE {$where_sql}", $where_values));
11213 } else {
11214 // No filters — delete all
11215 $result = $wpdb->query("DELETE FROM {$table_name}");
11216 }
11217
11218 if ($result === false) {
11219 $success = false;
11220 $error_messages[] = 'Failed to delete from WordPress database';
11221 }
11222 }
11223
11224 // Redirect back with a success message and bot_id
11225 $redirect_url = add_query_arg(array(
11226 'page' => 'mxchat-prompts',
11227 'bot_id' => $bot_id,
11228 'all_deleted' => $success ? 'true' : 'false'
11229 ), admin_url('admin.php'));
11230
11231 wp_safe_redirect($redirect_url);
11232 exit;
11233 }
11234
11235
11236
11237 /**
11238 * Handles deletion prompt with nonce validation
11239 */
11240 public function mxchat_handle_delete_prompt() {
11241 // Sanitize and validate nonce
11242 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
11243 if (empty($nonce) || !wp_verify_nonce($nonce, 'mxchat_delete_prompt_nonce')) {
11244 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
11245 }
11246
11247 // Check permissions
11248 if (!current_user_can('manage_options')) {
11249 wp_die(esc_html__('You do not have sufficient permissions to delete prompts.', 'mxchat'));
11250 }
11251
11252 // Get ID and source parameters
11253 $id = isset($_GET['id']) ? sanitize_text_field($_GET['id']) : '';
11254 $source = isset($_GET['source']) ? sanitize_text_field($_GET['source']) : '';
11255
11256 if (empty($id)) {
11257 wp_die(esc_html__('Invalid prompt ID.', 'mxchat'));
11258 }
11259
11260 $success = false;
11261 $error_message = '';
11262
11263 // Resolve per-bot Pinecone settings — the global option is the wrong
11264 // index entirely when the row belongs to a bot with its own host
11265 $bot_id = isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default';
11266 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
11267 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
11268 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
11269
11270 // If source is not specified, determine based on Pinecone configuration
11271 if (empty($source)) {
11272 $source = $use_pinecone ? 'pinecone' : 'wordpress';
11273 }
11274
11275 if ($source === 'pinecone' || $use_pinecone) {
11276 //error_log('[MXCHAT-DELETE] Deleting from Pinecone, ID: ' . $id);
11277
11278 // Handle Pinecone deletion
11279 if (empty($pinecone_options['mxchat_pinecone_host']) ||
11280 empty($pinecone_options['mxchat_pinecone_api_key'])) {
11281 wp_die(esc_html__('Pinecone configuration is missing.', 'mxchat'));
11282 }
11283
11284 // Delete from Pinecone using the vector ID directly
11285 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
11286 $id,
11287 $pinecone_options['mxchat_pinecone_api_key'],
11288 $pinecone_options['mxchat_pinecone_host'],
11289 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
11290 );
11291 if ($result['success']) {
11292 $success = true;
11293
11294 // Remove from vector cache
11295 $pinecone_manager->mxchat_remove_from_pinecone_vector_cache($id);
11296 $pinecone_manager->mxchat_remove_from_processed_content_caches($id);
11297
11298 set_transient('mxchat_admin_notice_success',
11299 esc_html__('Vector deleted successfully from Pinecone.', 'mxchat'), 30);
11300 } else {
11301 $error_message = $result['message'];
11302 set_transient('mxchat_admin_notice_error',
11303 esc_html__('Failed to delete from Pinecone: ', 'mxchat') . esc_html($error_message), 30);
11304 }
11305 } else {
11306 //error_log('[MXCHAT-DELETE] Deleting from WordPress database, ID: ' . $id);
11307
11308 // Handle WordPress database deletion
11309 global $wpdb;
11310 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
11311
11312 // Clear cache and delete prompt
11313 wp_cache_delete('prompt_' . $id, 'mxchat_prompts');
11314
11315 $result = $wpdb->delete(
11316 $table_name,
11317 array('id' => intval($id)),
11318 array('%d')
11319 );
11320
11321 if ($result !== false) {
11322 $success = true;
11323 set_transient('mxchat_admin_notice_success',
11324 esc_html__('Entry deleted successfully.', 'mxchat'), 30);
11325 } else {
11326 set_transient('mxchat_admin_notice_error',
11327 esc_html__('Failed to delete entry from database.', 'mxchat'), 30);
11328 }
11329 }
11330
11331 // Redirect back to the prompts page
11332 wp_safe_redirect(add_query_arg(
11333 array(
11334 'page' => 'mxchat-prompts',
11335 'deleted' => $success ? 'true' : 'false'
11336 ),
11337 admin_url('admin.php')
11338 ));
11339 exit;
11340 }
11341
11342
11343
11344
11345 /**
11346 * Averages multiple vectors into a single vector
11347 */
11348 private function mxchat_average_vectors($vectors) {
11349 $vector_length = count($vectors[0]);
11350 $sum_vector = array_fill(0, $vector_length, 0);
11351
11352 foreach ($vectors as $vector) {
11353 for ($i = 0; $i < $vector_length; $i++) {
11354 $sum_vector[$i] += $vector[$i];
11355 }
11356 }
11357
11358 // Divide each component by the number of vectors to get the average
11359 $num_vectors = count($vectors);
11360 for ($i = 0; $i < $vector_length; $i++) {
11361 $sum_vector[$i] /= $num_vectors;
11362 }
11363
11364 return $sum_vector;
11365 }
11366
11367
11368
11369
11370 /**
11371 * Generates embeddings from input text for MXChat
11372 */
11373 public function mxchat_generate_embedding($text) {
11374 // Enable detailed logging for debugging
11375 //error_log('[MXCHAT-EMBED] Starting embedding generation. Text length: ' . strlen($text) . ' bytes');
11376 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
11377
11378 $options = get_option('mxchat_options');
11379 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
11380 //error_log('[MXCHAT-EMBED] Selected embedding model: ' . $selected_model);
11381
11382 // Determine provider and endpoint
11383 if (strpos($selected_model, 'voyage') === 0) {
11384 $api_key = $options['voyage_api_key'] ?? '';
11385 $endpoint = 'https://api.voyageai.com/v1/embeddings';
11386 $provider_name = 'Voyage AI';
11387 //error_log('[MXCHAT-EMBED] Using Voyage AI API');
11388 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
11389 $api_key = $options['gemini_api_key'] ?? '';
11390 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
11391 $provider_name = 'Google Gemini';
11392 //error_log('[MXCHAT-EMBED] Using Google Gemini API');
11393 } else {
11394 $api_key = $options['api_key'] ?? '';
11395 $endpoint = 'https://api.openai.com/v1/embeddings';
11396 $provider_name = 'OpenAI';
11397 //error_log('[MXCHAT-EMBED] Using OpenAI API');
11398 }
11399
11400 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
11401
11402 if (empty($api_key)) {
11403 $error_message = sprintf('Missing %s API key. Please configure your API key in the MxChat settings.', $provider_name);
11404 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
11405 return $error_message;
11406 }
11407
11408 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
11409 $estimated_tokens = ceil(str_word_count($text) / 0.75);
11410 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
11411
11412 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
11413 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
11414 // Consider truncating text here
11415 }
11416
11417 // Prepare request body based on provider
11418 if (strpos($selected_model, 'gemini-embedding') === 0) {
11419 // Gemini API format
11420 $request_body = array(
11421 'model' => 'models/' . $selected_model,
11422 'content' => array(
11423 'parts' => array(
11424 array('text' => $text)
11425 )
11426 )
11427 );
11428
11429 // Set output dimensionality to 1536 for consistency with other models
11430 $request_body['outputDimensionality'] = 1536;
11431 } else {
11432 // OpenAI/Voyage API format
11433 $request_body = array(
11434 'model' => $selected_model,
11435 'input' => $text
11436 );
11437
11438 // Add output_dimension for voyage-3-large model
11439 if ($selected_model === 'voyage-3-large') {
11440 $request_body['output_dimension'] = 2048;
11441 }
11442 }
11443
11444 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
11445
11446 // Prepare headers based on provider
11447 if (strpos($selected_model, 'gemini-embedding') === 0) {
11448 // Gemini uses API key as query parameter
11449 $endpoint .= '?key=' . $api_key;
11450 $headers = array(
11451 'Content-Type' => 'application/json'
11452 );
11453 } else {
11454 // OpenAI/Voyage use Bearer token
11455 $headers = array(
11456 'Authorization' => 'Bearer ' . $api_key,
11457 'Content-Type' => 'application/json'
11458 );
11459 }
11460
11461 // Make API request
11462 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
11463 $response = wp_remote_post($endpoint, array(
11464 'body' => wp_json_encode($request_body),
11465 'headers' => $headers,
11466 'timeout' => 60 // Increased timeout for large inputs
11467 ));
11468
11469 // Handle wp_remote_post errors
11470 if (is_wp_error($response)) {
11471 $error_message = $response->get_error_message();
11472 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
11473 return 'Connection error: ' . $error_message;
11474 }
11475
11476 // Get and check HTTP response code
11477 $http_code = wp_remote_retrieve_response_code($response);
11478 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
11479
11480 if ($http_code !== 200) {
11481 $error_body = wp_remote_retrieve_body($response);
11482 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
11483
11484 // Try to parse error for more details
11485 $error_json = json_decode($error_body, true);
11486 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
11487 $error_type = $error_json['error']['type'] ?? 'unknown';
11488 $error_message = $error_json['error']['message'] ?? 'No message';
11489 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
11490 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
11491
11492 // Customize error message for common API errors
11493 if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
11494 $error_message = sprintf('Invalid %s API key. Please check your API key in the MxChat settings.', $provider_name);
11495 } elseif ($error_type === 'authentication_error') {
11496 $error_message = sprintf('%s authentication failed. Please verify your API key in the MxChat settings.', $provider_name);
11497 }
11498
11499 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
11500 return $error_message;
11501 }
11502
11503 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding", $http_code);
11504 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
11505 return $error_message;
11506 }
11507
11508 // Parse response body
11509 $response_body = wp_remote_retrieve_body($response);
11510 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
11511
11512 $response_data = json_decode($response_body, true);
11513
11514 if (json_last_error() !== JSON_ERROR_NONE) {
11515 $error = json_last_error_msg();
11516 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
11517 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
11518 return "Failed to parse API response: $error";
11519 }
11520
11521 // Handle different response formats based on provider
11522 if (strpos($selected_model, 'gemini-embedding') === 0) {
11523 // Gemini API response format
11524 if (isset($response_data['embedding']['values'])) {
11525 $embedding_dimensions = count($response_data['embedding']['values']);
11526 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
11527
11528 // Check if embedding dimensions are as expected (should be 1536)
11529 if ($embedding_dimensions !== 1536) {
11530 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
11531 }
11532
11533 MxChat_Utils::stamp_active_embedding_model($selected_model);
11534 return $response_data['embedding']['values'];
11535 } else {
11536 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
11537 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
11538
11539 if (isset($response_data['error'])) {
11540 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
11541 //error_log('[MXCHAT-EMBED] ' . $error_message);
11542 return $error_message;
11543 }
11544
11545 $error_message = "Invalid Gemini API response format: No embedding found";
11546 //error_log('[MXCHAT-EMBED] ' . $error_message);
11547 return $error_message;
11548 }
11549 } else {
11550 // OpenAI/Voyage API response format
11551 if (isset($response_data['data'][0]['embedding'])) {
11552 $embedding_dimensions = count($response_data['data'][0]['embedding']);
11553 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
11554
11555 // Check if embedding dimensions are as expected
11556 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
11557 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
11558 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
11559 }
11560
11561 MxChat_Utils::stamp_active_embedding_model($selected_model);
11562 return $response_data['data'][0]['embedding'];
11563 } else {
11564 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
11565 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
11566
11567 if (isset($response_data['error'])) {
11568 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
11569 //error_log('[MXCHAT-EMBED] ' . $error_message);
11570 return $error_message;
11571 }
11572
11573 $error_message = "Invalid API response format: No embedding found";
11574 //error_log('[MXCHAT-EMBED] ' . $error_message);
11575 return $error_message;
11576 }
11577 }
11578 }
11579
11580
11581
11582 }
11583 ?>
11584