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

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

12,036 lines 537.3 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 // Lead capture options (c0cfaf). Both default to today's behaviour:
451 // the form is hidden from logged-in visitors, and email is required.
452 'lead_capture_logged_in_toggle' => 'off',
453 'lead_capture_require_email_toggle' => 'on',
454 'top_bar_title' => esc_html__('MxChat', 'mxchat'),
455 'intro_message' => __('Hello! How can I assist you today?', 'mxchat'),
456 'ai_agent_text' => esc_html__('AI Agent', 'mxchat'),
457 'input_copy' => esc_html__('How can I assist?', 'mxchat'),
458 'append_to_body' => 'off',
459 'post_type_visibility_mode' => 'all', // 'all', 'include', 'exclude'
460 'post_type_visibility_list' => array(), // Array of post type slugs
461 'contextual_awareness_toggle' => 'off',
462 'citation_links_toggle' => 'on',
463 'satisfaction_rating_enabled' => 'off',
464 'satisfaction_rating_idle_seconds' => 60,
465 'satisfaction_rating_question' => '',
466 'satisfaction_rating_thanks' => '',
467 'satisfaction_rating_placeholder' => '',
468 'satisfaction_rating_saved' => '',
469 'close_button_color' => '#fff',
470 'chatbot_bg_color' => '#fff',
471 'user_message_bg_color' => '#fff',
472 'user_message_font_color' => '#212121',
473 'bot_message_bg_color' => '#212121',
474 'bot_message_font_color' => '#fff',
475 'top_bar_bg_color' => '#212121',
476 'send_button_font_color' => '#212121',
477 'chat_input_font_color' => '#212121',
478 'chatbot_background_color' => '#212121',
479 'icon_color' => '#fff',
480 'enable_woocommerce_integration' => '0',
481 'link_target_toggle' => 'off',
482 'pre_chat_message' => esc_html__('Hey there! Ask me anything!', 'mxchat'),
483
484 // New fields for Loops Integration
485 'loops_api_key' => '',
486 'loops_mailing_list' => '',
487 'triggered_phrase_response' => __('Would you like to join our mailing list? Please provide your email below.', 'mxchat'),
488 'email_capture_response' => __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat'),
489 'popular_question_1' => '',
490 'popular_question_2' => '',
491 'popular_question_3' => '',
492 'pdf_intent_trigger_text' => __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat'),
493 'pdf_intent_success_text' => __("I've processed the PDF. What questions do you have about it?", 'mxchat'),
494 'pdf_intent_error_text' => __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat'),
495 'pdf_max_pages' => 69,
496 'show_pdf_upload_button' => 'on',
497 'show_word_upload_button' => 'on',
498
499 // Live Agent Integration (Slack)
500 'live_agent_webhook_url' => '',
501 'live_agent_secret_key' => '',
502 'live_agent_bot_token' => '',
503 'live_agent_message_bg_color' => '#ffffff',
504 'live_agent_message_font_color' => '#333333',
505
506 // Telegram Integration
507 'telegram_status' => 'off',
508 'telegram_bot_token' => '',
509 'telegram_group_id' => '',
510 'telegram_webhook_secret' => '',
511 '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'),
512 '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'),
513
514 // Webhook handoff destination (plan d88e22, outbound-only)
515 'webhook_handoff_status' => 'off',
516 'webhook_handoff_url' => '',
517 'webhook_handoff_secret' => '',
518 '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'),
519 'webhook_handoff_away_message' => __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat'),
520
521 'chat_toolbar_toggle' => 'off',
522 'mode_indicator_bg_color' => '#767676',
523 'mode_indicator_font_color' => '#ffffff',
524 'toolbar_icon_color' => '#212121',
525
526 // Optimization settings
527 'script_loading_strategy' => 'default',
528
529 // Debug settings
530 'debug_mode' => 'off',
531 );
532
533
534 // Merge existing options with defaults
535 $existing_options = get_option('mxchat_options', array());
536 $merged_options = wp_parse_args($existing_options, $default_options);
537
538 // Update the options if they have changed
539 if ($existing_options !== $merged_options) {
540 update_option('mxchat_options', $merged_options);
541 }
542
543 // Add default limits for each role
544 $roles = wp_roles()->get_names();
545 foreach ($roles as $role_id => $role_name) {
546 $default_options['role_rate_limits'][$role_id] = '100';
547 }
548
549 return $default_options;
550
551 // Update the $this->options property
552 $this->options = $merged_options;
553 }
554
555 public function mxchat_add_plugin_page() {
556 // Onboarding lifecycle helpers (admin_init redirect + ajax handlers live in the file).
557 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
558
559 // Main menu page — `mxchat-max` remains the parent slug for every MxChat submenu
560 // (Settings, Knowledge, Transcripts, …). Hitting `?page=mxchat-max` directly now
561 // dispatches to the Onboarding page (or Settings if the user has dismissed onboarding).
562 add_menu_page(
563 esc_html__('MxChat', 'mxchat'),
564 esc_html__('MxChat', 'mxchat'),
565 'manage_options',
566 'mxchat-max',
567 array($this, 'mxchat_create_dashboard_page'),
568 'dashicons-testimonial',
569 6
570 );
571
572 // Onboarding submenu — first child under MxChat (plan-d14e89).
573 // First registration uses menu_slug === parent slug 'mxchat-max' →
574 // WP-canonical override of the auto-duplicate "MxChat" entry. Result: the
575 // first child shows as "Onboarding" instead of a redundant pair.
576 add_submenu_page(
577 'mxchat-max',
578 esc_html__('MxChat Onboarding', 'mxchat'),
579 esc_html__('Onboarding', 'mxchat'),
580 'manage_options',
581 'mxchat-max',
582 array($this, 'mxchat_create_dashboard_page')
583 );
584 // Hidden route for `?page=mxchat-onboarding` (Settings "Show again" link
585 // + legacy redirects still target this slug). Parent === null keeps it
586 // out of the menu while remaining accessible by URL.
587 add_submenu_page(
588 null,
589 esc_html__('MxChat Onboarding', 'mxchat'),
590 esc_html__('Onboarding', 'mxchat'),
591 'manage_options',
592 'mxchat-onboarding',
593 array($this, 'mxchat_create_dashboard_page')
594 );
595
596 // Settings submenu — same callback as before, just at a new slug.
597 add_submenu_page(
598 'mxchat-max',
599 esc_html__('MxChat Settings', 'mxchat'),
600 esc_html__('Settings', 'mxchat'),
601 'manage_options',
602 'mxchat-settings',
603 array($this, 'mxchat_create_admin_page')
604 );
605
606 // Submenu page for Knowledge
607 add_submenu_page(
608 'mxchat-max',
609 esc_html__('Prompts', 'mxchat'),
610 esc_html__('Knowledge', 'mxchat'),
611 'manage_options',
612 'mxchat-prompts',
613 array($this, 'mxchat_create_prompts_page')
614 );
615
616 add_submenu_page(
617 'mxchat-max',
618 esc_html__('Chat Transcripts', 'mxchat'),
619 esc_html__('Transcripts', 'mxchat'),
620 'manage_options',
621 'mxchat-transcripts',
622 array($this, 'mxchat_create_transcripts_page')
623 );
624
625 add_submenu_page(
626 'mxchat-max',
627 esc_html__('MxChat Actions', 'mxchat'),
628 esc_html__('Actions', 'mxchat'),
629 'manage_options',
630 'mxchat-actions',
631 array($this, 'mxchat_actions_page_html')
632 );
633
634 // Content Generator page
635 add_submenu_page(
636 'mxchat-max',
637 esc_html__('Content', 'mxchat'),
638 esc_html__('Content', 'mxchat'),
639 'manage_options',
640 'mxchat-content',
641 array($this, 'mxchat_create_content_page')
642 );
643
644 }
645
646 /**
647 * Register the Pro & Extensions submenu on a later admin_menu priority so it
648 * always renders as the bottom-most item in the MxChat sidebar — below
649 * configuration pages like API Access (priority 20).
650 */
651 public function mxchat_add_pro_extensions_page() {
652 add_submenu_page(
653 'mxchat-max',
654 esc_html__('Pro & Extensions', 'mxchat'),
655 esc_html__('Pro & Extensions', 'mxchat'),
656 'manage_options',
657 'mxchat-activation',
658 array($this, 'mxchat_create_activation_page')
659 );
660 }
661
662 public function mxchat_create_addons_page() {
663 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
664 $addons_page = new MxChat_Addons();
665 $addons_page->render_page();
666 }
667
668 /**
669 * Render the Content Generator admin page
670 */
671 public function mxchat_create_content_page() {
672 require_once plugin_dir_path(__FILE__) . 'admin-content-page.php';
673 // The Editor Assistant toggle on the Settings tab renders through
674 // mxchat_render_field_wrapper(), which lives in admin-settings-page.php
675 // (plan-f7df40 relocation) — load it the same way the settings page does.
676 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
677 mxchat_render_content_page($this);
678 }
679
680 /**
681 * Test actual streaming functionality in the WordPress environment
682 */
683 public function mxchat_handle_test_streaming_actual() {
684 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
685
686 // A nonce is not authorization (plan-mxchat-20260731-c63fb6). This handler
687 // reads the site's provider keys and spends them on an outbound call, so it
688 // is billable abuse for any logged-in user who obtained the nonce.
689 if (!current_user_can('manage_options')) {
690 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
691 }
692
693 // Check if headers have already been sent
694 if (headers_sent()) {
695 wp_send_json_error(['message' => 'Headers already sent - streaming not possible']);
696 return;
697 }
698
699 // Check for required functions
700 if (!function_exists('curl_init')) {
701 wp_send_json_error(['message' => 'cURL not available - streaming requires cURL']);
702 return;
703 }
704
705 // Get user's selected model and API key
706 $options = get_option('mxchat_options', []);
707 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
708
709 // Get the provider from the model
710 $model_parts = explode('-', $selected_model);
711 $provider = strtolower($model_parts[0]);
712
713 // Get the appropriate API key
714 $api_key = '';
715 switch ($provider) {
716 case 'gpt':
717 case 'o1':
718 $api_key = $options['api_key'] ?? '';
719 break;
720 case 'claude':
721 $api_key = $options['claude_api_key'] ?? '';
722 break;
723 case 'grok':
724 $api_key = $options['xai_api_key'] ?? '';
725 break;
726 case 'deepseek':
727 $api_key = $options['deepseek_api_key'] ?? '';
728 break;
729 case 'gemini':
730 $api_key = $options['gemini_api_key'] ?? '';
731 break;
732 default:
733 // Default to OpenAI for unknown models
734 $api_key = $options['api_key'] ?? '';
735 $provider = 'gpt';
736 break;
737 }
738
739 if (empty($api_key)) {
740 wp_send_json_error(['message' => "API key not configured for {$provider} provider"]);
741 return;
742 }
743
744 // Test streaming with the selected model and provider
745 try {
746 $this->perform_streaming_test($provider, $selected_model, $api_key);
747 } catch (Exception $e) {
748 wp_send_json_error(['message' => 'Streaming test exception: ' . $e->getMessage()]);
749 }
750 }
751
752 /**
753 * Perform the actual streaming test
754 */
755 private function perform_streaming_test($provider, $model, $api_key) {
756 // Set streaming headers
757 header('Content-Type: text/event-stream');
758 header('Cache-Control: no-cache');
759 header('X-Accel-Buffering: no'); // Disable nginx buffering
760
761 // Prepare test message
762 $test_message = "Please respond with exactly: 'Streaming test successful!' - send this as a short response for testing.";
763
764 // Configure API request based on provider
765 $url = '';
766 $headers = [];
767 $body = [];
768
769 switch ($provider) {
770 case 'gpt':
771 case 'o1':
772 $url = 'https://api.openai.com/v1/chat/completions';
773 $headers = [
774 'Content-Type: application/json',
775 'Authorization: Bearer ' . $api_key
776 ];
777 $body = [
778 'model' => $model,
779 'messages' => [['role' => 'user', 'content' => $test_message]],
780 'max_tokens' => 50,
781 'temperature' => 0.3,
782 'stream' => true
783 ];
784 break;
785
786 case 'claude':
787 $url = 'https://api.anthropic.com/v1/messages';
788 $headers = [
789 'Content-Type: application/json',
790 'x-api-key: ' . $api_key,
791 'anthropic-version: 2023-06-01'
792 ];
793 $body = [
794 'model' => $model,
795 'messages' => [['role' => 'user', 'content' => $test_message]],
796 'max_tokens' => 50,
797 'temperature' => 0.3,
798 'stream' => true
799 ];
800 // Claude flagships from Opus 4.7 onward reject the temperature
801 // param outright (400). Reuse the catalog's decision — this path
802 // previously sent temperature unconditionally, so the streaming
803 // test was broken for Opus 4.7/4.8, Fable 5 and Sonnet 5.
804 if (class_exists('MxChat_Model_Catalog')
805 && method_exists('MxChat_Model_Catalog', 'supports_temperature')
806 && !MxChat_Model_Catalog::supports_temperature($model)) {
807 unset($body['temperature']);
808 }
809 break;
810
811 case 'grok':
812 $url = 'https://api.x.ai/v1/chat/completions';
813 $headers = [
814 'Content-Type: application/json',
815 'Authorization: Bearer ' . $api_key
816 ];
817 $body = [
818 'model' => $model,
819 'messages' => [['role' => 'user', 'content' => $test_message]],
820 'max_tokens' => 50,
821 'temperature' => 0.3,
822 'stream' => true
823 ];
824 break;
825
826 case 'deepseek':
827 $url = 'https://api.deepseek.com/v1/chat/completions';
828 $headers = [
829 'Content-Type: application/json',
830 'Authorization: Bearer ' . $api_key
831 ];
832 $body = [
833 'model' => $model,
834 'messages' => [['role' => 'user', 'content' => $test_message]],
835 'max_tokens' => 50,
836 'temperature' => 0.3,
837 'stream' => true,
838 // DeepSeek V4 defaults to thinking mode ON — reasoning would
839 // consume the 50-token test budget and return no visible text.
840 'thinking' => ['type' => 'disabled']
841 ];
842 break;
843
844 default:
845 echo "data: " . json_encode(['error' => 'Unsupported provider for streaming test: ' . $provider]) . "\n\n";
846 flush();
847 return;
848 }
849
850 // Initialize cURL
851 $ch = curl_init();
852 curl_setopt($ch, CURLOPT_URL, $url);
853 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
854 curl_setopt($ch, CURLOPT_POST, true);
855 curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
856 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
857 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
858 curl_setopt($ch, CURLOPT_TIMEOUT, 30);
859 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use ($provider) {
860 return $this->process_streaming_test_data($data, $provider);
861 });
862
863 // Send initial test message
864 echo "data: " . json_encode(['content' => '[Starting streaming test...]']) . "\n\n";
865 flush();
866
867 $result = curl_exec($ch);
868 $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
869 $curl_error = curl_error($ch);
870 curl_close($ch);
871
872 if ($curl_error) {
873 echo "data: " . json_encode(['error' => 'cURL Error: ' . $curl_error]) . "\n\n";
874 flush();
875 return;
876 }
877
878 if ($http_code !== 200) {
879 echo "data: " . json_encode(['error' => 'API returned HTTP ' . $http_code]) . "\n\n";
880 flush();
881 return;
882 }
883
884 // Send completion signal
885 echo "data: [DONE]\n\n";
886 flush();
887 }
888
889 /**
890 * Process streaming data for the test
891 */
892 private function process_streaming_test_data($data, $provider) {
893 static $chunk_count = 0;
894
895 $lines = explode("\n", $data);
896
897 foreach ($lines as $line) {
898 if (trim($line) === '') {
899 continue;
900 }
901
902 // Handle different provider formats
903 if ($provider === 'claude') {
904 // Claude uses event: and data: format
905 if (strpos($line, 'data: ') === 0) {
906 $json_str = substr($line, 6);
907 $json = json_decode($json_str, true);
908
909 if (isset($json['type']) && $json['type'] === 'content_block_delta') {
910 if (isset($json['delta']['text'])) {
911 $chunk_count++;
912 echo "data: " . json_encode([
913 'content' => $json['delta']['text'],
914 'test_chunk' => $chunk_count
915 ]) . "\n\n";
916 flush();
917 }
918 }
919 }
920 } else {
921 // OpenAI, X.AI, DeepSeek format
922 if (strpos($line, 'data: ') === 0) {
923 $json_str = substr($line, 6);
924
925 if ($json_str === '[DONE]') {
926 // Don't echo [DONE] here, let the main function handle it
927 continue;
928 }
929
930 $json = json_decode($json_str, true);
931 if (isset($json['choices'][0]['delta']['content'])) {
932 $chunk_count++;
933 echo "data: " . json_encode([
934 'content' => $json['choices'][0]['delta']['content'],
935 'test_chunk' => $chunk_count
936 ]) . "\n\n";
937 flush();
938 }
939 }
940 }
941 }
942
943 return strlen($data);
944 }
945
946 /**
947 * Updated version of your existing test method (keep this as a fallback)
948 */
949 public function mxchat_handle_test_streaming() {
950 check_ajax_referer('mxchat_test_streaming_nonce', 'nonce');
951
952 // plan-mxchat-20260731-c63fb6 — the delegate below checks this too, but
953 // fail here so the alias never becomes a bypass if that call is refactored.
954 if (!current_user_can('manage_options')) {
955 wp_send_json_error(['message' => esc_html__('Unauthorized', 'mxchat')], 403);
956 }
957
958 // Use the actual streaming test instead
959 $this->mxchat_handle_test_streaming_actual();
960 }
961
962
963 public function register_pinecone_settings() {
964 register_setting(
965 'mxchat_pinecone_addon_options',
966 'mxchat_pinecone_addon_options',
967 array(
968 'type' => 'array',
969 'sanitize_callback' => array($this, 'sanitize_pinecone_settings'),
970 'default' => array(
971 'mxchat_use_pinecone' => '0',
972 'mxchat_pinecone_api_key' => '',
973 'mxchat_pinecone_host' => '',
974 'mxchat_pinecone_index' => '',
975 'mxchat_pinecone_environment' => '',
976 'mxchat_pinecone_top_k' => '50'
977 )
978 )
979 );
980 }
981
982 public function sanitize_pinecone_settings($input) {
983 $sanitized = array();
984
985 // The form posts value="1" for a ticked box and no key for an unticked one; a caller
986 // that passes '0' explicitly must not switch Pinecone ON (plan 95b2da).
987 $sanitized['mxchat_use_pinecone'] = (isset($input['mxchat_use_pinecone']) && (string) $input['mxchat_use_pinecone'] === '1') ? '1' : '0';
988 $sanitized['mxchat_pinecone_api_key'] = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
989 $sanitized['mxchat_pinecone_host'] = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
990 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
991 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
992
993 // d0cae1: this function REBUILDS the array, so the topK key must be
994 // carried here or any form save strips it. Clamp mirrors the read site.
995 $top_k = absint($input['mxchat_pinecone_top_k'] ?? 50);
996 $sanitized['mxchat_pinecone_top_k'] = (string) (($top_k >= 1 && $top_k <= 1000) ? $top_k : 50);
997
998 // Remove https:// from host if present
999 $sanitized['mxchat_pinecone_host'] = str_replace(['https://', 'http://'], '', $sanitized['mxchat_pinecone_host']);
1000
1001 // Index type (plan 362c31). The document index is opt-in, and its host
1002 // must have passed Check index (or come from Create index for me) before
1003 // the choice sticks — a classic host routed through the documents API
1004 // answers 400 on every query. The verified host is carried from the
1005 // stored option; only the AJAX handlers set it, never the form.
1006 $existing = get_option('mxchat_pinecone_addon_options', array());
1007 $existing = is_array($existing) ? $existing : array();
1008 $verified_host = (string) ($existing['mxchat_pinecone_docs_verified_host'] ?? '');
1009 $index_type = (($input['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector';
1010 $host_for_check = strtolower(trim($sanitized['mxchat_pinecone_host'], '/'));
1011 if ($index_type === 'document' && ($host_for_check === '' || $host_for_check !== strtolower($verified_host))) {
1012 $index_type = 'vector';
1013 if (function_exists('add_settings_error')) {
1014 add_settings_error('mxchat_pinecone_addon_options', 'mxchat_pinecone_docs_unverified', __('The document index host has not been checked, so the index type stayed on Vector index. Click Check index (or Create index for me) and save again.', 'mxchat'));
1015 }
1016 }
1017 // 3e83e4: never point the live chatbot at a document index whose copy is
1018 // still running — the save keeps the previous type unless the owner
1019 // ticked Switch anyway. Only a vector → document switch is gated.
1020 $existing_type = (($existing['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector';
1021 if ($index_type === 'document' && $existing_type !== 'document' && class_exists('MxChat_Pinecone_Documents') && empty($input['mxchat_pinecone_docs_switch_anyway'])) {
1022 $running = MxChat_Pinecone_Documents::migration_blocks_switch($host_for_check);
1023 if ($running !== null) {
1024 $index_type = 'vector';
1025 if (function_exists('add_settings_error')) {
1026 add_settings_error('mxchat_pinecone_addon_options', 'mxchat_pinecone_docs_copy_running', MxChat_Pinecone_Documents::switch_blocked_message($running));
1027 }
1028 }
1029 }
1030 $sanitized['mxchat_pinecone_index_type'] = $index_type;
1031 $sanitized['mxchat_pinecone_docs_verified_host'] = $verified_host;
1032 $vector_host = str_replace(['https://', 'http://'], '', sanitize_text_field($input['mxchat_pinecone_vector_host'] ?? ($existing['mxchat_pinecone_vector_host'] ?? '')));
1033 $sanitized['mxchat_pinecone_vector_host'] = trim($vector_host, '/');
1034 $sanitized['mxchat_pinecone_vector_index'] = sanitize_text_field($input['mxchat_pinecone_vector_index'] ?? ($existing['mxchat_pinecone_vector_index'] ?? ''));
1035 $docs_cloud = sanitize_key($input['mxchat_pinecone_docs_cloud'] ?? ($existing['mxchat_pinecone_docs_cloud'] ?? 'aws'));
1036 $sanitized['mxchat_pinecone_docs_cloud'] = in_array($docs_cloud, array('aws', 'gcp', 'azure'), true) ? $docs_cloud : 'aws';
1037 $docs_region = sanitize_text_field($input['mxchat_pinecone_docs_region'] ?? ($existing['mxchat_pinecone_docs_region'] ?? 'us-east-1'));
1038 $sanitized['mxchat_pinecone_docs_region'] = $docs_region !== '' ? $docs_region : 'us-east-1';
1039 $docs_language = sanitize_key($input['mxchat_pinecone_docs_language'] ?? ($existing['mxchat_pinecone_docs_language'] ?? 'en'));
1040 $sanitized['mxchat_pinecone_docs_language'] = (class_exists('MxChat_Pinecone_Documents') && array_key_exists($docs_language, MxChat_Pinecone_Documents::languages())) ? $docs_language : 'en';
1041 // The namespace has no form field; carry the stored value so a form save cannot drop it.
1042 if (isset($existing['mxchat_pinecone_namespace'])) {
1043 $sanitized['mxchat_pinecone_namespace'] = sanitize_text_field($existing['mxchat_pinecone_namespace']);
1044 }
1045
1046 return $sanitized;
1047 }
1048
1049 public function register_openai_vectorstore_settings() {
1050 register_setting(
1051 'mxchat_openai_vectorstore_options',
1052 'mxchat_openai_vectorstore_options',
1053 array(
1054 'type' => 'array',
1055 'sanitize_callback' => array($this, 'sanitize_openai_vectorstore_settings'),
1056 'default' => array(
1057 'mxchat_use_openai_vectorstore' => '0',
1058 'mxchat_vectorstore_ids' => '',
1059 'mxchat_vectorstore_max_results' => 5,
1060 'mxchat_vectorstore_sync_enabled' => '0',
1061 'mxchat_vectorstore_sync_store_id' => ''
1062 )
1063 )
1064 );
1065 }
1066
1067 public function sanitize_openai_vectorstore_settings($input) {
1068 $sanitized = array();
1069
1070 $sanitized['mxchat_use_openai_vectorstore'] = isset($input['mxchat_use_openai_vectorstore']) ? '1' : '0';
1071 $sanitized['mxchat_vectorstore_ids'] = sanitize_text_field($input['mxchat_vectorstore_ids'] ?? '');
1072 $sanitized['mxchat_vectorstore_max_results'] = absint($input['mxchat_vectorstore_max_results'] ?? 5);
1073
1074 // Knowledge-base sync (write path, plan 15b5c6). Store ID may be blank —
1075 // the sync falls back to the first retrieval ID above.
1076 $sanitized['mxchat_vectorstore_sync_enabled'] = isset($input['mxchat_vectorstore_sync_enabled']) ? '1' : '0';
1077 $sanitized['mxchat_vectorstore_sync_store_id'] = sanitize_text_field($input['mxchat_vectorstore_sync_store_id'] ?? '');
1078
1079 // Ensure max results is within reasonable range
1080 if ($sanitized['mxchat_vectorstore_max_results'] < 1) {
1081 $sanitized['mxchat_vectorstore_max_results'] = 1;
1082 }
1083 if ($sanitized['mxchat_vectorstore_max_results'] > 20) {
1084 $sanitized['mxchat_vectorstore_max_results'] = 20;
1085 }
1086
1087 return $sanitized;
1088 }
1089
1090 /**
1091 * AJAX handler to test OpenAI Vector Store connection
1092 */
1093 public function mxchat_test_vectorstore_connection() {
1094 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1095
1096 if (!current_user_can('manage_options')) {
1097 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1098 return;
1099 }
1100
1101 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
1102 $vectorstore_ids = $vectorstore_options['mxchat_vectorstore_ids'] ?? '';
1103 $mxchat_options = get_option('mxchat_options', array());
1104 $api_key = $mxchat_options['api_key'] ?? '';
1105
1106 if (empty($api_key)) {
1107 wp_send_json_error(array('message' => __('OpenAI API key is not configured.', 'mxchat')));
1108 return;
1109 }
1110
1111 if (empty($vectorstore_ids)) {
1112 wp_send_json_error(array('message' => __('No Vector Store ID configured.', 'mxchat')));
1113 return;
1114 }
1115
1116 // Get the first Vector Store ID for testing
1117 $ids_array = array_map('trim', explode(',', $vectorstore_ids));
1118 $test_id = $ids_array[0];
1119
1120 // Test by retrieving the Vector Store info
1121 $response = wp_remote_get(
1122 'https://api.openai.com/v1/vector_stores/' . $test_id,
1123 array(
1124 'headers' => array(
1125 'Authorization' => 'Bearer ' . $api_key,
1126 'Content-Type' => 'application/json',
1127 'OpenAI-Beta' => 'assistants=v2'
1128 ),
1129 'timeout' => 30
1130 )
1131 );
1132
1133 if (is_wp_error($response)) {
1134 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $response->get_error_message()));
1135 return;
1136 }
1137
1138 $status_code = wp_remote_retrieve_response_code($response);
1139 $body = json_decode(wp_remote_retrieve_body($response), true);
1140
1141 if ($status_code === 200 && isset($body['id'])) {
1142 $file_count = $body['file_counts']['completed'] ?? 0;
1143 $name = $body['name'] ?? $test_id;
1144 wp_send_json_success(array(
1145 'message' => sprintf(
1146 __('Connected successfully! Vector Store: %s (%d files)', 'mxchat'),
1147 esc_html($name),
1148 $file_count
1149 )
1150 ));
1151 } elseif ($status_code === 404) {
1152 wp_send_json_error(array('message' => __('Vector Store not found. Please check the ID.', 'mxchat')));
1153 } elseif ($status_code === 401) {
1154 wp_send_json_error(array('message' => __('Invalid API key.', 'mxchat')));
1155 } else {
1156 $error_message = $body['error']['message'] ?? __('Unknown error occurred.', 'mxchat');
1157 wp_send_json_error(array('message' => $error_message));
1158 }
1159 }
1160
1161 /**
1162 * AJAX handler to test Slack connection and validate scopes
1163 */
1164 public function mxchat_test_slack_connection() {
1165 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1166
1167 if (!current_user_can('manage_options')) {
1168 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1169 return;
1170 }
1171
1172 $bot_token = $this->options['live_agent_bot_token'] ?? '';
1173
1174 if (empty($bot_token)) {
1175 wp_send_json_error(array('message' => __('Slack Bot Token is not configured. Please enter your token and save settings first.', 'mxchat')));
1176 return;
1177 }
1178
1179 // Test 1: Validate bot token with auth.test
1180 $auth_response = wp_remote_post('https://slack.com/api/auth.test', array(
1181 'headers' => array(
1182 'Authorization' => 'Bearer ' . $bot_token,
1183 'Content-Type' => 'application/json'
1184 ),
1185 'timeout' => 15
1186 ));
1187
1188 if (is_wp_error($auth_response)) {
1189 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $auth_response->get_error_message()));
1190 return;
1191 }
1192
1193 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
1194
1195 if (!isset($auth_body['ok']) || !$auth_body['ok']) {
1196 $error = $auth_body['error'] ?? 'unknown_error';
1197 $error_messages = array(
1198 'invalid_auth' => __('Invalid bot token. Please check your token starts with xoxb-', 'mxchat'),
1199 'not_authed' => __('No authentication token provided.', 'mxchat'),
1200 'account_inactive' => __('The Slack workspace has been deactivated.', 'mxchat'),
1201 'token_revoked' => __('The bot token has been revoked. Please generate a new one.', 'mxchat'),
1202 );
1203 $message = $error_messages[$error] ?? sprintf(__('Authentication failed: %s', 'mxchat'), $error);
1204 wp_send_json_error(array('message' => $message));
1205 return;
1206 }
1207
1208 $team_name = $auth_body['team'] ?? 'Unknown Workspace';
1209 $bot_name = $auth_body['user'] ?? 'Unknown Bot';
1210
1211 // Test 2: Check if we can list channels (tests channels:read scope)
1212 $channels_response = wp_remote_post('https://slack.com/api/conversations.list', array(
1213 'headers' => array(
1214 'Authorization' => 'Bearer ' . $bot_token,
1215 'Content-Type' => 'application/json'
1216 ),
1217 'body' => json_encode(array('limit' => 1)),
1218 'timeout' => 15
1219 ));
1220
1221 $channels_body = json_decode(wp_remote_retrieve_body($channels_response), true);
1222 $can_read_channels = isset($channels_body['ok']) && $channels_body['ok'];
1223
1224 // Test 3: Check if we can create channels (tests channels:manage scope)
1225 // We'll just check the error message without actually creating
1226 $create_response = wp_remote_post('https://slack.com/api/conversations.create', array(
1227 'headers' => array(
1228 'Authorization' => 'Bearer ' . $bot_token,
1229 'Content-Type' => 'application/json'
1230 ),
1231 'body' => json_encode(array('name' => 'mxchat-test-' . time(), 'is_private' => false)),
1232 'timeout' => 15
1233 ));
1234
1235 $create_body = json_decode(wp_remote_retrieve_body($create_response), true);
1236
1237 // If channel was created, delete it immediately
1238 if (isset($create_body['ok']) && $create_body['ok'] && isset($create_body['channel']['id'])) {
1239 wp_remote_post('https://slack.com/api/conversations.archive', array(
1240 'headers' => array(
1241 'Authorization' => 'Bearer ' . $bot_token,
1242 'Content-Type' => 'application/json'
1243 ),
1244 'body' => json_encode(array('channel' => $create_body['channel']['id'])),
1245 'timeout' => 15
1246 ));
1247 $can_create_channels = true;
1248 } else {
1249 $create_error = $create_body['error'] ?? '';
1250 // name_taken means we have permission but channel exists
1251 $can_create_channels = ($create_error === 'name_taken' || (isset($create_body['ok']) && $create_body['ok']));
1252
1253 // Check for missing scope errors
1254 if ($create_error === 'missing_scope') {
1255 $can_create_channels = false;
1256 }
1257 }
1258
1259 // Build result message
1260 $results = array();
1261 $results[] = sprintf(__('Workspace: %s', 'mxchat'), esc_html($team_name));
1262 $results[] = sprintf(__('Bot: %s', 'mxchat'), esc_html($bot_name));
1263 $results[] = '';
1264 $results[] = ($can_read_channels ? '✓' : '✗') . ' ' . __('channels:read - List channels', 'mxchat');
1265 $results[] = ($can_create_channels ? '✓' : '✗') . ' ' . __('channels:manage - Create channels', 'mxchat');
1266
1267 $missing_scopes = array();
1268 if (!$can_read_channels) $missing_scopes[] = 'channels:read';
1269 if (!$can_create_channels) $missing_scopes[] = 'channels:manage';
1270
1271 if (!empty($missing_scopes)) {
1272 wp_send_json_error(array(
1273 'message' => implode("\n", $results),
1274 'missing_scopes' => $missing_scopes,
1275 'partial' => true
1276 ));
1277 } else {
1278 wp_send_json_success(array(
1279 'message' => implode("\n", $results)
1280 ));
1281 }
1282 }
1283
1284 public function mxchat_display_admin_notice() {
1285 // Success notice
1286 if ($message = get_transient('mxchat_admin_notice_success')) {
1287 ?>
1288 <div class="notice notice-success is-dismissible">
1289 <p><?php echo esc_html($message); ?></p>
1290 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1291 </div>
1292 <?php
1293 delete_transient('mxchat_admin_notice_success'); // Clear the transient after displaying
1294 }
1295
1296 // Error notice
1297 if ($message = get_transient('mxchat_admin_notice_error')) {
1298 ?>
1299 <div class="notice notice-error is-dismissible">
1300 <p><?php echo esc_html($message); ?></p>
1301 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1302 </div>
1303 <?php
1304 delete_transient('mxchat_admin_notice_error'); // Clear the transient after displaying
1305 }
1306 }
1307
1308 public function show_live_agent_disabled_banner() {
1309 $show_disabled_notice = get_option('mxchat_show_live_agent_disabled_notice', false);
1310
1311 if ($show_disabled_notice) {
1312 ?>
1313 <div class="mxchat-live-agent-disabled-notice" id="mxchat-disabled-notice">
1314 <div class="mxchat-pro-notification">
1315 <button type="button" class="mxchat-dismiss-btn" onclick="dismissLiveAgentNotice()" aria-label="Dismiss notification">
1316 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1317 <line x1="18" y1="6" x2="6" y2="18"></line>
1318 <line x1="6" y1="6" x2="18" y2="18"></line>
1319 </svg>
1320 </button>
1321 <div class="mxchat-live-agent-content">
1322 <h3>🔧 Live Agent Integration Updated!</h3>
1323 <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>
1324 </div>
1325 </div>
1326 </div>
1327 <?php
1328 }
1329 }
1330 /**
1331 * Render the Onboarding page. Delegates to the procedural renderer in
1332 * includes/admin-onboarding-page.php. Wired to both `?page=mxchat-max`
1333 * (legacy top-level URL) and `?page=mxchat-onboarding` (the canonical
1334 * Onboarding submenu).
1335 *
1336 * When the user has dismissed onboarding and lands on `mxchat-max` (the
1337 * legacy URL, since the Onboarding submenu has been removed), redirect to
1338 * Settings instead — the page is "graduated" and we shouldn't dump them
1339 * back onto it. They can still navigate here directly via the unhide link.
1340 */
1341 public function mxchat_create_dashboard_page() {
1342 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
1343
1344 $current = isset($_GET['page']) ? sanitize_key($_GET['page']) : '';
1345 if ($current === 'mxchat-max' && function_exists('mxchat_onboarding_is_dismissed') && mxchat_onboarding_is_dismissed()) {
1346 wp_safe_redirect(admin_url('admin.php?page=mxchat-settings'));
1347 exit;
1348 }
1349
1350 if (function_exists('mxchat_render_onboarding_page')) {
1351 mxchat_render_onboarding_page();
1352 return;
1353 }
1354 // Defensive: if the include failed to load, fall back to the old Settings page
1355 // so the top-level menu never lands on an empty screen.
1356 $this->mxchat_create_admin_page();
1357 }
1358
1359 /**
1360 * Hide the Onboarding submenu when the user has dismissed it (either
1361 * manually or via auto-graduation). The page itself remains routable so
1362 * the Settings "Show MxChat Onboarding again" link can navigate back to it.
1363 */
1364 public function mxchat_apply_onboarding_visibility() {
1365 if (!function_exists('mxchat_onboarding_is_dismissed')) {
1366 return;
1367 }
1368 if (mxchat_onboarding_is_dismissed()) {
1369 // The first MxChat child is the same-slug-as-parent registration
1370 // (slug 'mxchat-max', labelled "Onboarding") added in plan-d14e89.
1371 // Remove it so the menu opens straight to Settings after dismiss.
1372 remove_submenu_page('mxchat-max', 'mxchat-max');
1373 }
1374 }
1375
1376 public function mxchat_create_admin_page() {
1377 $this->add_live_agent_nonce();
1378 $this->add_theme_migration_nonce();
1379
1380 // Include and render the new sidebar-based settings page
1381 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
1382 mxchat_render_settings_page($this);
1383 }
1384
1385 public function dismiss_live_agent_notice() {
1386 // Add debugging
1387 //error_log('dismiss_live_agent_notice called');
1388 //error_log('POST data: ' . print_r($_POST, true));
1389
1390 // Verify nonce
1391 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_live_agent_notice')) {
1392 //error_log('Nonce verification failed');
1393 wp_die('Security check failed');
1394 }
1395
1396 // Remove the notice flag
1397 $deleted = delete_option('mxchat_show_live_agent_disabled_notice');
1398 //error_log('Option deleted: ' . ($deleted ? 'yes' : 'no'));
1399
1400 wp_send_json_success();
1401 }
1402 public function add_live_agent_nonce() {
1403 if (get_option('mxchat_show_live_agent_disabled_notice', false)) {
1404 // Make sure your admin script is enqueued and localize the data
1405 wp_localize_script('mxchat-admin-js', 'mxchatLiveAgent', array(
1406 'nonce' => wp_create_nonce('dismiss_live_agent_notice'),
1407 'ajaxurl' => admin_url('admin-ajax.php')
1408 ));
1409 }
1410 }
1411
1412 /**
1413 * Show theme migration notice for Pro users with AI-generated themes
1414 * Only shown once - dismissible and stored in options
1415 */
1416 public function show_theme_migration_banner() {
1417 // Only show if Pro is activated
1418 if (!$this->is_activated) {
1419 return;
1420 }
1421
1422 // Check if notice should be shown
1423 $show_notice = get_option('mxchat_show_theme_migration_notice', false);
1424
1425 if ($show_notice) {
1426 ?>
1427 <div class="mxchat-theme-migration-notice" id="mxchat-theme-migration-notice">
1428 <div class="mxchat-pro-notification">
1429 <button type="button" class="mxchat-dismiss-btn" onclick="dismissThemeMigrationNotice()" aria-label="Dismiss notification">
1430 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1431 <line x1="18" y1="6" x2="6" y2="18"></line>
1432 <line x1="6" y1="6" x2="18" y2="18"></line>
1433 </svg>
1434 </button>
1435 <div class="mxchat-theme-migration-content">
1436 <h3>🎨 AI Theme Migration Required</h3>
1437 <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>
1438 </div>
1439 </div>
1440 </div>
1441 <?php
1442 }
1443 }
1444
1445 /**
1446 * Dismiss theme migration notice via AJAX
1447 */
1448 public function dismiss_theme_migration_notice() {
1449 // Verify nonce
1450 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_theme_migration_notice')) {
1451 wp_die('Security check failed');
1452 }
1453
1454 // Remove the notice flag
1455 delete_option('mxchat_show_theme_migration_notice');
1456
1457 wp_send_json_success();
1458 }
1459
1460 /**
1461 * Add nonce for theme migration notice dismiss
1462 */
1463 public function add_theme_migration_nonce() {
1464 if (get_option('mxchat_show_theme_migration_notice', false) && $this->is_activated) {
1465 wp_localize_script('mxchat-admin-js', 'mxchatThemeMigration', array(
1466 'nonce' => wp_create_nonce('dismiss_theme_migration_notice'),
1467 'ajaxurl' => admin_url('admin-ajax.php')
1468 ));
1469 }
1470 }
1471
1472 public function mxchat_create_transcripts_page() {
1473 global $wpdb;
1474 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1475
1476 // Get basic stats
1477 $total_chats = $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM $table_name") ?: 0;
1478 $total_messages = $wpdb->get_var("SELECT COUNT(*) FROM $table_name") ?: 0;
1479
1480 // Count unique users with detailed breakdown
1481 $total_users = $wpdb->get_var("
1482 SELECT COUNT(DISTINCT
1483 CASE
1484 WHEN user_email != '' AND user_email IS NOT NULL THEN user_email
1485 WHEN user_id != 0 THEN CONCAT('user_', user_id)
1486 WHEN user_identifier NOT LIKE 'Tech-Savvy User'
1487 AND user_identifier NOT LIKE 'Detail-Oriented User'
1488 AND user_identifier NOT LIKE 'Language Learner'
1489 AND user_identifier NOT LIKE 'Casual Browser'
1490 AND user_identifier NOT LIKE 'Policy Enforcer'
1491 AND user_identifier NOT LIKE 'Researcher'
1492 AND user_identifier NOT LIKE 'Loyalty Member'
1493 AND user_identifier NOT LIKE 'Gift Buyer'
1494 AND user_identifier NOT LIKE 'Parent or Caregiver'
1495 THEN user_identifier
1496 ELSE session_id
1497 END
1498 )
1499 FROM $table_name
1500 WHERE role != 'assistant'
1501 ");
1502
1503 // Get user type breakdown
1504 $registered_users = $wpdb->get_var("
1505 SELECT COUNT(DISTINCT user_email)
1506 FROM $table_name
1507 WHERE user_email != '' AND user_email IS NOT NULL
1508 ");
1509
1510 $guest_users = $wpdb->get_var("
1511 SELECT COUNT(DISTINCT user_identifier)
1512 FROM $table_name
1513 WHERE (user_email = '' OR user_email IS NULL)
1514 AND role != 'assistant'
1515 AND user_identifier NOT LIKE 'Tech-Savvy User'
1516 AND user_identifier NOT LIKE 'Detail-Oriented User'
1517 AND user_identifier NOT LIKE 'Language Learner'
1518 AND user_identifier NOT LIKE 'Casual Browser'
1519 AND user_identifier NOT LIKE 'Policy Enforcer'
1520 AND user_identifier NOT LIKE 'Researcher'
1521 AND user_identifier NOT LIKE 'Loyalty Member'
1522 AND user_identifier NOT LIKE 'Gift Buyer'
1523 AND user_identifier NOT LIKE 'Parent or Caregiver'
1524 ");
1525
1526 // Get agent test messages count
1527 $agent_tests = $wpdb->get_var("
1528 SELECT COUNT(DISTINCT session_id)
1529 FROM $table_name
1530 WHERE user_identifier IN (
1531 'Tech-Savvy User',
1532 'Detail-Oriented User',
1533 'Language Learner',
1534 'Casual Browser',
1535 'Policy Enforcer',
1536 'Researcher',
1537 'Loyalty Member',
1538 'Gift Buyer',
1539 'Parent or Caregiver'
1540 )
1541 ");
1542 // Get activity metrics
1543 $today_chats = $wpdb->get_var("
1544 SELECT COUNT(DISTINCT session_id)
1545 FROM $table_name
1546 WHERE DATE(timestamp) = CURDATE()
1547 ");
1548
1549 $week_chats = $wpdb->get_var("
1550 SELECT COUNT(DISTINCT session_id)
1551 FROM $table_name
1552 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1553 ");
1554
1555 $month_chats = $wpdb->get_var("
1556 SELECT COUNT(DISTINCT session_id)
1557 FROM $table_name
1558 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
1559 ");
1560
1561 // Get daily chat data for last 7 days
1562 $daily_stats = $wpdb->get_results("
1563 SELECT
1564 DATE(timestamp) as date,
1565 COUNT(DISTINCT session_id) as chats,
1566 COUNT(*) as messages
1567 FROM $table_name
1568 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1569 GROUP BY DATE(timestamp)
1570 ORDER BY date ASC
1571 ");
1572
1573 // Get average messages per chat
1574 $avg_messages = $wpdb->get_var("
1575 SELECT AVG(message_count)
1576 FROM (
1577 SELECT session_id, COUNT(*) as message_count
1578 FROM $table_name
1579 GROUP BY session_id
1580 ) as chat_counts
1581 ");
1582 $avg_messages = $avg_messages ? round($avg_messages, 1) : 0;
1583
1584 // Get busiest hour
1585 $busiest_hour = $wpdb->get_row("
1586 SELECT HOUR(timestamp) as hour, COUNT(DISTINCT session_id) as chat_count
1587 FROM $table_name
1588 GROUP BY HOUR(timestamp)
1589 ORDER BY chat_count DESC
1590 LIMIT 1
1591 ");
1592
1593 // Prepare chart data
1594 $chart_labels = array();
1595 $chart_chats = array();
1596 $chart_messages = array();
1597
1598 // Fill last 7 days with data
1599 for ($i = 6; $i >= 0; $i--) {
1600 $date = date('Y-m-d', strtotime("-$i days"));
1601 $day_name = date('D', strtotime("-$i days"));
1602 $chart_labels[] = $day_name;
1603
1604 $found = false;
1605 foreach ($daily_stats as $stat) {
1606 if ($stat->date === $date) {
1607 $chart_chats[] = (int)$stat->chats;
1608 $chart_messages[] = (int)$stat->messages;
1609 $found = true;
1610 break;
1611 }
1612 }
1613 if (!$found) {
1614 $chart_chats[] = 0;
1615 $chart_messages[] = 0;
1616 }
1617 }
1618
1619 // Satisfaction rating rollup — last 30 days, grouped by bot (plan-a5b006).
1620 $satisfaction_stats = $this->get_satisfaction_rating_stats(30);
1621
1622 // Prepare page data for the template
1623 $page_data = array(
1624 'total_chats' => $total_chats,
1625 'total_messages' => $total_messages,
1626 'total_users' => $total_users,
1627 'registered_users' => $registered_users,
1628 'guest_users' => $guest_users,
1629 'agent_tests' => $agent_tests,
1630 'today_chats' => $today_chats,
1631 'week_chats' => $week_chats,
1632 'month_chats' => $month_chats,
1633 'avg_messages' => $avg_messages,
1634 'busiest_hour' => $busiest_hour,
1635 'chart_labels' => $chart_labels,
1636 'chart_chats' => $chart_chats,
1637 'chart_messages' => $chart_messages,
1638 'satisfaction_stats' => $satisfaction_stats,
1639 );
1640
1641 // Include and render the new template
1642 require_once plugin_dir_path(__FILE__) . 'admin-transcripts-page.php';
1643 mxchat_render_transcripts_page($this, $page_data);
1644 }
1645
1646 /**
1647 * Per-bot satisfaction rating rollup over the last $days days. Used by the
1648 * Satisfaction card on the Transcripts dashboard (plan-a5b006).
1649 *
1650 * @param int $days Window in days.
1651 * @return array Each entry: ['bot_id', 'total', 'positive', 'negative', 'positive_pct', 'negative_pct'].
1652 */
1653 public function get_satisfaction_rating_stats($days = 30) {
1654 global $wpdb;
1655 $table = $wpdb->prefix . 'mxchat_session_ratings';
1656 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
1657 return array();
1658 }
1659 $days = max(1, (int) $days);
1660 $rows = $wpdb->get_results($wpdb->prepare(
1661 "SELECT bot_id,
1662 COUNT(*) AS total,
1663 SUM(CASE WHEN rating_value = 1 THEN 1 ELSE 0 END) AS positive,
1664 SUM(CASE WHEN rating_value = -1 THEN 1 ELSE 0 END) AS negative
1665 FROM {$table}
1666 WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
1667 GROUP BY bot_id
1668 ORDER BY total DESC",
1669 $days
1670 ));
1671 $out = array();
1672 foreach ((array) $rows as $row) {
1673 $total = (int) $row->total;
1674 $positive = (int) $row->positive;
1675 $negative = (int) $row->negative;
1676 $out[] = array(
1677 'bot_id' => $row->bot_id ?: 'default',
1678 'total' => $total,
1679 'positive' => $positive,
1680 'negative' => $negative,
1681 'positive_pct' => $total > 0 ? (int) round(($positive / $total) * 100) : 0,
1682 'negative_pct' => $total > 0 ? (int) round(($negative / $total) * 100) : 0,
1683 );
1684 }
1685 return $out;
1686 }
1687
1688 /**
1689 * Get chart data for transcripts page
1690 * Used by both page render and script localization
1691 *
1692 * @return array Chart data with labels, chats, and messages arrays
1693 */
1694 private function get_transcripts_chart_data() {
1695 global $wpdb;
1696 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1697
1698 // Get daily chat data for last 7 days - same query as mxchat_transcripts_page()
1699 $daily_stats = $wpdb->get_results("
1700 SELECT
1701 DATE(timestamp) as date,
1702 COUNT(DISTINCT session_id) as chats,
1703 COUNT(*) as messages
1704 FROM $table_name
1705 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1706 GROUP BY DATE(timestamp)
1707 ORDER BY date ASC
1708 ");
1709
1710 // Prepare chart data
1711 $chart_labels = array();
1712 $chart_chats = array();
1713 $chart_messages = array();
1714
1715 // Fill last 7 days with data - same logic as mxchat_transcripts_page()
1716 for ($i = 6; $i >= 0; $i--) {
1717 $date = date('Y-m-d', strtotime("-$i days"));
1718 $day_name = date('D', strtotime("-$i days"));
1719 $chart_labels[] = $day_name;
1720
1721 $found = false;
1722 if ($daily_stats) {
1723 foreach ($daily_stats as $stat) {
1724 if ($stat->date === $date) {
1725 $chart_chats[] = (int)$stat->chats;
1726 $chart_messages[] = (int)$stat->messages;
1727 $found = true;
1728 break;
1729 }
1730 }
1731 }
1732 if (!$found) {
1733 $chart_chats[] = 0;
1734 $chart_messages[] = 0;
1735 }
1736 }
1737
1738 return array(
1739 'labels' => $chart_labels,
1740 'chats' => $chart_chats,
1741 'messages' => $chart_messages
1742 );
1743 }
1744
1745 public function mxchat_transcripts_notification_section_callback() {
1746 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>';
1747 }
1748 public function mxchat_enable_notifications_callback() {
1749 $options = get_option('mxchat_transcripts_options', array());
1750 $enabled = isset($options['mxchat_enable_notifications']) ? $options['mxchat_enable_notifications'] : 0;
1751 ?>
1752 <label for="mxchat_enable_notifications">
1753 <input type="checkbox" id="mxchat_enable_notifications"
1754 name="mxchat_transcripts_options[mxchat_enable_notifications]"
1755 value="1" <?php checked(1, $enabled); ?>>
1756 <?php esc_html_e('Send email notification when a new chat session starts', 'mxchat'); ?>
1757 </label>
1758 <p class="description">
1759 <?php esc_html_e('Enable this option to receive email notifications for new chat sessions.', 'mxchat'); ?>
1760 </p>
1761 <?php
1762 }
1763 public function mxchat_notification_email_callback() {
1764 $options = get_option('mxchat_transcripts_options', array());
1765 $email = isset($options['mxchat_notification_email']) ? $options['mxchat_notification_email'] : get_option('admin_email');
1766 ?>
1767 <?php /* type="text", not "email": the browser rejects a comma-separated list outright (plan 2f131a). Validation is server-side in MxChat_Utils. */ ?>
1768 <input type="text" id="mxchat_notification_email"
1769 name="mxchat_transcripts_options[mxchat_notification_email]"
1770 value="<?php echo esc_attr($email); ?>"
1771 class="regular-text">
1772 <p class="description">
1773 <?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); ?>
1774 </p>
1775 <?php
1776 }
1777
1778 public function mxchat_auto_delete_transcripts_callback() {
1779 $options = get_option('mxchat_transcripts_options', array());
1780 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1781 ?>
1782 <select id="mxchat_auto_delete_transcripts"
1783 name="mxchat_transcripts_options[mxchat_auto_delete_transcripts]">
1784 <option value="never" <?php selected($interval, 'never'); ?>>
1785 <?php esc_html_e('Never (Keep All Transcripts)', 'mxchat'); ?>
1786 </option>
1787 <option value="1week" <?php selected($interval, '1week'); ?>>
1788 <?php esc_html_e('After 1 Week', 'mxchat'); ?>
1789 </option>
1790 <option value="2weeks" <?php selected($interval, '2weeks'); ?>>
1791 <?php esc_html_e('After 2 Weeks', 'mxchat'); ?>
1792 </option>
1793 <option value="1month" <?php selected($interval, '1month'); ?>>
1794 <?php esc_html_e('After 1 Month', 'mxchat'); ?>
1795 </option>
1796 </select>
1797 <p class="description">
1798 <?php esc_html_e('Automatically delete old chat transcripts after the selected time period. This helps manage database size and privacy.', 'mxchat'); ?>
1799 </p>
1800 <?php
1801 }
1802
1803 /**
1804 * Custom retention-days input. When > 0 it overrides the bucket dropdown above
1805 * and deletes transcripts older than the given number of days. Set to 0 to fall
1806 * back to the dropdown (or "Never" if the dropdown is also Never).
1807 *
1808 * Devs can override the final day count via the `mxchat_transcript_retention_days`
1809 * filter — runs in `cleanup_old_transcripts()` after this option is read.
1810 *
1811 * (plan-mxchat-20260509-9b80b1)
1812 */
1813 public function mxchat_retention_days_callback() {
1814 $options = get_option('mxchat_transcripts_options', array());
1815 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1816 ?>
1817 <input type="number"
1818 id="mxchat_retention_days"
1819 name="mxchat_transcripts_options[mxchat_retention_days]"
1820 value="<?php echo esc_attr($days); ?>"
1821 min="0"
1822 max="3650"
1823 step="1"
1824 style="width: 90px;" />
1825 <p class="description">
1826 <?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'); ?>
1827 <br>
1828 <code>apply_filters( 'mxchat_transcript_retention_days', $days )</code>
1829 <?php esc_html_e('lets developers override the final day count programmatically.', 'mxchat'); ?>
1830 </p>
1831 <?php
1832 }
1833
1834 public function mxchat_auto_email_transcript_callback() {
1835 $options = get_option('mxchat_transcripts_options', array());
1836 $enabled = isset($options['mxchat_auto_email_transcript_enabled']) ? $options['mxchat_auto_email_transcript_enabled'] : 0;
1837 $delay = isset($options['mxchat_auto_email_transcript_delay']) ? $options['mxchat_auto_email_transcript_delay'] : '30';
1838 $require_contact = isset($options['mxchat_auto_email_transcript_require_contact']) ? $options['mxchat_auto_email_transcript_require_contact'] : 0;
1839 ?>
1840 <label>
1841 <input type="checkbox"
1842 id="mxchat_auto_email_transcript_enabled"
1843 name="mxchat_transcripts_options[mxchat_auto_email_transcript_enabled]"
1844 value="1"
1845 <?php checked($enabled, 1); ?>>
1846 <?php esc_html_e('Enable Auto-Email of Full Transcript', 'mxchat'); ?>
1847 </label>
1848 <br><br>
1849 <label for="mxchat_auto_email_transcript_delay">
1850 <?php esc_html_e('Send transcript after:', 'mxchat'); ?>
1851 </label>
1852 <select id="mxchat_auto_email_transcript_delay"
1853 name="mxchat_transcripts_options[mxchat_auto_email_transcript_delay]">
1854 <option value="15" <?php selected($delay, '15'); ?>>
1855 <?php esc_html_e('15 minutes', 'mxchat'); ?>
1856 </option>
1857 <option value="30" <?php selected($delay, '30'); ?>>
1858 <?php esc_html_e('30 minutes', 'mxchat'); ?>
1859 </option>
1860 <option value="60" <?php selected($delay, '60'); ?>>
1861 <?php esc_html_e('1 hour', 'mxchat'); ?>
1862 </option>
1863 </select>
1864 <br><br>
1865 <label>
1866 <input type="checkbox"
1867 id="mxchat_auto_email_transcript_require_contact"
1868 name="mxchat_transcripts_options[mxchat_auto_email_transcript_require_contact]"
1869 value="1"
1870 <?php checked($require_contact, 1); ?>>
1871 <?php esc_html_e('Only send if visitor provided contact info', 'mxchat'); ?>
1872 </label>
1873 <p class="description">
1874 <?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'); ?>
1875 <br>
1876 <?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'); ?>
1877 </p>
1878 <?php
1879 }
1880
1881
1882
1883 public function sanitize_transcripts_options($input) {
1884 $sanitized = array();
1885
1886 $sanitized['mxchat_enable_notifications'] = isset($input['mxchat_enable_notifications']) ? 1 : 0;
1887
1888 if (isset($input['mxchat_notification_email'])) {
1889 // Same list rule as the Transcripts autosave path (plan 2f131a). These two
1890 // paths disagreeing is how the original bug survived: this one DID call
1891 // is_email(), but on sanitize_email()'s output — which is valid even when
1892 // the input was two addresses mashed into one. See MxChat_Utils.
1893 $parsed = MxChat_Utils::parse_notification_emails($input['mxchat_notification_email']);
1894 if ($parsed['error'] !== '') {
1895 add_settings_error(
1896 'mxchat_transcripts_options',
1897 'invalid_email',
1898 $parsed['error'],
1899 'error'
1900 );
1901 // Keep whatever was already stored. Do NOT fall back to admin_email:
1902 // that fallback belongs to a genuinely EMPTY field, and using it here
1903 // would quietly redirect notifications away from the address on screen.
1904 $existing = get_option('mxchat_transcripts_options', array());
1905 if (is_array($existing) && isset($existing['mxchat_notification_email'])) {
1906 $sanitized['mxchat_notification_email'] = $existing['mxchat_notification_email'];
1907 }
1908 } else {
1909 $sanitized['mxchat_notification_email'] = implode(', ', $parsed['emails']);
1910 }
1911 }
1912
1913 // Sanitize auto-delete setting
1914 $valid_intervals = array('never', '1week', '2weeks', '1month');
1915 if (isset($input['mxchat_auto_delete_transcripts'])) {
1916 $sanitized['mxchat_auto_delete_transcripts'] = in_array($input['mxchat_auto_delete_transcripts'], $valid_intervals)
1917 ? $input['mxchat_auto_delete_transcripts']
1918 : 'never';
1919 } else {
1920 $sanitized['mxchat_auto_delete_transcripts'] = 'never';
1921 }
1922
1923 // Sanitize custom retention-days override (plan-9b80b1).
1924 if (isset($input['mxchat_retention_days'])) {
1925 $days = (int) $input['mxchat_retention_days'];
1926 $sanitized['mxchat_retention_days'] = max(0, min(3650, $days));
1927 } else {
1928 // Absent key = a partial save (WP-CLI patch, a snippet, any
1929 // update_option with a subset — this filter runs on ALL of them).
1930 // Preserve the stored value instead of zeroing it: resetting here
1931 // silently discarded a hand-set retention on the next unrelated
1932 // save of this option (plan-3c3338). The page's own autosave path
1933 // bypasses this filter entirely (direct $wpdb write), so this
1934 // branch only ever sees programmatic saves.
1935 $mxch_prev = get_option('mxchat_transcripts_options', array());
1936 $sanitized['mxchat_retention_days'] = (is_array($mxch_prev) && isset($mxch_prev['mxchat_retention_days']))
1937 ? max(0, min(3650, (int) $mxch_prev['mxchat_retention_days']))
1938 : 0;
1939 }
1940
1941 // Get old values to check if auto-delete or retention-days changed.
1942 $old_options = get_option('mxchat_transcripts_options');
1943 $old_interval = isset($old_options['mxchat_auto_delete_transcripts']) ? $old_options['mxchat_auto_delete_transcripts'] : 'never';
1944 $old_retention = isset($old_options['mxchat_retention_days']) ? (int) $old_options['mxchat_retention_days'] : 0;
1945
1946 // If either setting changed, reschedule the cron job. The schedule_transcript_cleanup
1947 // helper now treats "any active retention" (dropdown != never OR custom days > 0) as a
1948 // reason to keep the daily cron registered.
1949 $interval_changed = ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']);
1950 $retention_changed = ($old_retention !== $sanitized['mxchat_retention_days']);
1951 if ($interval_changed || $retention_changed) {
1952 $any_active = ($sanitized['mxchat_auto_delete_transcripts'] !== 'never') || ($sanitized['mxchat_retention_days'] > 0);
1953 $this->schedule_transcript_cleanup($any_active ? 'active' : 'never');
1954 }
1955
1956 // Sanitize auto-email transcript settings
1957 $sanitized['mxchat_auto_email_transcript_enabled'] = isset($input['mxchat_auto_email_transcript_enabled']) ? 1 : 0;
1958
1959 $valid_delays = array('15', '30', '60');
1960 if (isset($input['mxchat_auto_email_transcript_delay'])) {
1961 $sanitized['mxchat_auto_email_transcript_delay'] = in_array($input['mxchat_auto_email_transcript_delay'], $valid_delays)
1962 ? $input['mxchat_auto_email_transcript_delay']
1963 : '30';
1964 } else {
1965 $sanitized['mxchat_auto_email_transcript_delay'] = '30';
1966 }
1967
1968 // Sanitize require contact info setting
1969 $sanitized['mxchat_auto_email_transcript_require_contact'] = isset($input['mxchat_auto_email_transcript_require_contact']) ? 1 : 0;
1970
1971 return $sanitized;
1972 }
1973
1974 /**
1975 * Schedule or unschedule the transcript cleanup cron job
1976 */
1977 public function schedule_transcript_cleanup($interval) {
1978 // Clear any existing scheduled event
1979 $timestamp = wp_next_scheduled('mxchat_cleanup_old_transcripts');
1980 if ($timestamp) {
1981 wp_unschedule_event($timestamp, 'mxchat_cleanup_old_transcripts');
1982 }
1983
1984 // Schedule new event whenever retention is active. "active" is the canonical
1985 // value passed by sanitize_transcripts_options when either the dropdown != never
1986 // OR the custom retention-days > 0; "never" turns the cron off. Any other value
1987 // (the legacy "1week" / "2weeks" / "1month" strings) is also treated as active.
1988 if ($interval !== 'never') {
1989 // Schedule to run daily at 3 AM
1990 $next_run = strtotime('tomorrow 3:00 AM');
1991 wp_schedule_event($next_run, 'daily', 'mxchat_cleanup_old_transcripts');
1992 }
1993 }
1994
1995 /**
1996 * Self-heal guard: keep the cleanup cron in sync with the retention setting.
1997 *
1998 * Runs on admin_init. Cost when nothing is wrong: one get_option() (cached) and
1999 * one wp_next_scheduled() (reads the cached cron option) — no writes. It only
2000 * schedules when retention is active but the event is missing, and only
2001 * unschedules when retention is off but the event survived. Both directions
2002 * reuse schedule_transcript_cleanup() so there is exactly one scheduling path.
2003 * Legacy dropdown values (1week/2weeks/1month) count as active, matching the
2004 * helper's own semantics. Deliberately not gated on DISABLE_WP_CRON: scheduling
2005 * writes the cron option regardless of how cron is executed, so a server-side
2006 * cron runner still picks the event up.
2007 */
2008 public function ensure_transcript_cleanup_scheduled() {
2009 $options = get_option('mxchat_transcripts_options', array());
2010 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
2011 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
2012 $active = ($interval !== 'never') || ($days > 0);
2013 $scheduled = (bool) wp_next_scheduled('mxchat_cleanup_old_transcripts');
2014
2015 if ($active && !$scheduled) {
2016 $this->schedule_transcript_cleanup('active');
2017 } elseif (!$active && $scheduled) {
2018 $this->schedule_transcript_cleanup('never');
2019 }
2020 }
2021
2022 /**
2023 * Delete old transcripts based on the configured interval
2024 */
2025 public function cleanup_old_transcripts() {
2026 $options = get_option('mxchat_transcripts_options', array());
2027 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
2028 $custom_days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
2029
2030 // Custom retention-days (plan-9b80b1) takes precedence over the bucket dropdown.
2031 $days = 0;
2032 if ($custom_days > 0) {
2033 $days = $custom_days;
2034 } else {
2035 switch ($interval) {
2036 case '1week': $days = 7; break;
2037 case '2weeks': $days = 14; break;
2038 case '1month': $days = 30; break;
2039 case 'never':
2040 default:
2041 $days = 0;
2042 }
2043 }
2044
2045 // Devs can override the final day count programmatically.
2046 $days = (int) apply_filters('mxchat_transcript_retention_days', $days);
2047
2048 if ($days <= 0) {
2049 return; // Retention disabled — bail.
2050 }
2051
2052 global $wpdb;
2053 $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
2054 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2055 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
2056
2057 // Defensive: if the main table doesn't exist (fresh-ish install), bail.
2058 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
2059 return;
2060 }
2061
2062 $cutoff_date = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
2063
2064 // Cap at 5000 session_ids per run so a large unattended site doesn't OOM —
2065 // the cron will pick up where it left off on the next tick (within hours).
2066 $batch_cap = (int) apply_filters('mxchat_transcript_retention_batch_cap', 5000);
2067
2068 $sessions_to_delete = $wpdb->get_col(
2069 $wpdb->prepare(
2070 "SELECT DISTINCT session_id FROM {$transcripts_table} WHERE timestamp IS NOT NULL AND timestamp < %s LIMIT %d",
2071 $cutoff_date,
2072 $batch_cap
2073 )
2074 );
2075
2076 if (empty($sessions_to_delete)) {
2077 return;
2078 }
2079
2080 $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s'));
2081
2082 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2083 // $placeholders is a server-built list of literal "%s" tokens.
2084 $deleted_transcripts = (int) $wpdb->query(
2085 $wpdb->prepare(
2086 "DELETE FROM {$transcripts_table} WHERE session_id IN ($placeholders)",
2087 $sessions_to_delete
2088 )
2089 );
2090
2091 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
2092 $wpdb->query(
2093 $wpdb->prepare(
2094 "DELETE FROM {$translations_table} WHERE session_id IN ($placeholders)",
2095 $sessions_to_delete
2096 )
2097 );
2098 }
2099
2100 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
2101 $wpdb->query(
2102 $wpdb->prepare(
2103 "DELETE FROM {$url_clicks_table} WHERE session_id IN ($placeholders)",
2104 $sessions_to_delete
2105 )
2106 );
2107 }
2108 // phpcs:enable
2109
2110 update_option('mxchat_retention_last_swept_at', time(), false);
2111 update_option('mxchat_retention_rows_last_deleted', $deleted_transcripts, false);
2112 }
2113
2114 public function export_chat_transcripts() {
2115 if (!current_user_can('manage_options')) {
2116 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2117 }
2118
2119 check_ajax_referer('mxchat_export_transcripts', 'security');
2120
2121 global $wpdb;
2122 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
2123
2124 // Get all transcripts ordered by session and timestamp
2125 $results = $wpdb->get_results(
2126 "SELECT session_id, user_email, user_identifier, role, message, timestamp
2127 FROM {$table_name}
2128 ORDER BY session_id, timestamp ASC"
2129 );
2130
2131 if (empty($results)) {
2132 wp_send_json_error(array('message' => 'No transcripts found.'));
2133 wp_die();
2134 }
2135
2136 // Set headers for CSV download
2137 header('Content-Type: text/csv');
2138 header('Content-Disposition: attachment; filename="chat-transcripts-' . date('Y-m-d') . '.csv"');
2139 header('Pragma: no-cache');
2140 header('Expires: 0');
2141
2142 // Create output stream
2143 $output = fopen('php://output', 'w');
2144
2145 // Add UTF-8 BOM for proper Excel encoding
2146 fputs($output, "\xEF\xBB\xBF");
2147
2148 // Add CSV headers
2149 fputcsv($output, array(
2150 'Session ID',
2151 'Email',
2152 'User Identifier',
2153 'Role',
2154 'Message',
2155 'Timestamp'
2156 ));
2157
2158 // Add data rows
2159 foreach ($results as $row) {
2160 fputcsv($output, array(
2161 $row->session_id,
2162 $row->user_email,
2163 $row->user_identifier,
2164 $row->role,
2165 $row->message,
2166 $row->timestamp
2167 ));
2168 }
2169
2170 fclose($output);
2171 wp_die();
2172 }
2173
2174 // ============================================================================
2175 // Leads tab (inside Transcripts)
2176 //
2177 // Leads are derived from existing data — no dedicated table. Primary source:
2178 // wp_mxchat_chat_transcripts rows where user_email is populated. Secondary
2179 // source: the mxchat_sessions table's visitor_email/visitor_name columns
2180 // (plus any unmigrated legacy `mxchat_email_{sid}` wp_options rows) for
2181 // "orphan" leads who submitted the pre-chat form but never chatted.
2182 // ============================================================================
2183
2184 /**
2185 * Fetch leads: dedup-by-email rows, stats strip, and top pages in one call.
2186 */
2187 public function mxchat_fetch_leads() {
2188 if (!current_user_can('manage_options')) {
2189 wp_send_json_error(['message' => 'Insufficient permissions']);
2190 wp_die();
2191 }
2192
2193 $date_range = isset($_POST['date_range']) ? sanitize_key($_POST['date_range']) : 'all';
2194
2195 wp_send_json(self::mxchat_collect_leads([
2196 'page' => isset($_POST['page']) ? max(1, absint($_POST['page'])) : 1,
2197 'per_page' => isset($_POST['per_page']) ? min(100, max(10, absint($_POST['per_page']))) : 25,
2198 'search' => isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '',
2199 'date_cutoff' => self::mxchat_leads_date_cutoff($date_range),
2200 'status' => isset($_POST['status']) ? sanitize_key($_POST['status']) : 'all',
2201 'page_url' => isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '',
2202 'sort' => isset($_POST['sort']) ? sanitize_key($_POST['sort']) : 'last_seen',
2203 'sort_dir' => (isset($_POST['sort_dir']) && $_POST['sort_dir'] === 'asc') ? 'asc' : 'desc',
2204 ]));
2205 wp_die();
2206 }
2207
2208 /**
2209 * Build the Leads payload: the transcripts-derived list plus the chat-deleted
2210 * and orphan buckets, paginated and hydrated exactly as the Leads tab shows it.
2211 *
2212 * Extracted from mxchat_fetch_leads() so the REST route (f958cf) reads leads
2213 * through the same query rather than growing a second one that would drift —
2214 * in particular from the address-less lead key introduced by c0cfaf. Callers
2215 * own their own capability check and their own per_page ceiling; this method
2216 * reads no superglobals and echoes nothing.
2217 *
2218 * @param array $args page, per_page, search, date_cutoff (MySQL datetime or ''),
2219 * status (all|orphan|chat_deleted), page_url, sort, sort_dir.
2220 * @return array
2221 */
2222 public static function mxchat_collect_leads(array $args = []) {
2223 global $wpdb;
2224 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2225
2226 $page = max(1, (int) ($args['page'] ?? 1));
2227 $per_page = max(1, (int) ($args['per_page'] ?? 25));
2228 $offset = ($page - 1) * $per_page;
2229 $search = (string) ($args['search'] ?? '');
2230 $status = (string) ($args['status'] ?? 'all');
2231 $page_filter = (string) ($args['page_url'] ?? '');
2232 $sort = (string) ($args['sort'] ?? 'last_seen');
2233 $sort_dir = (isset($args['sort_dir']) && $args['sort_dir'] === 'asc') ? 'ASC' : 'DESC';
2234
2235 $date_cutoff = (string) ($args['date_cutoff'] ?? '');
2236 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2237
2238 // Base WHERE for transcripts leads.
2239 //
2240 // Email is the row key whenever there IS one. With "Require Email Address"
2241 // turned off (c0cfaf) a capture can be name-only, and keying on email
2242 // would drop it from this list entirely — the lead would be collected and
2243 // then invisible. Those rows key on session id instead, written as
2244 // "session:<id>" so the two kinds of key can never collide.
2245 //
2246 // The widening is gated on the option: while email is required (the
2247 // default, and every install that has not opted in) both the WHERE and the
2248 // GROUP BY reduce to exactly what they were before.
2249 $lead_key_sql = self::mxchat_leads_key_sql();
2250 $email_optional = self::mxchat_leads_email_optional();
2251
2252 $where_clauses = $email_optional
2253 ? ["((user_email IS NOT NULL AND user_email != '') OR (user_name IS NOT NULL AND user_name != ''))"]
2254 : ["user_email IS NOT NULL", "user_email != ''"];
2255 $where_params = [];
2256
2257 if ($date_cutoff) {
2258 $where_clauses[] = 'timestamp >= %s';
2259 $where_params[] = $date_cutoff;
2260 }
2261 if ($page_filter && $has_page_url_column) {
2262 $where_clauses[] = 'originating_page_url = %s';
2263 $where_params[] = $page_filter;
2264 }
2265 if ($search !== '') {
2266 $like = '%' . $wpdb->esc_like($search) . '%';
2267 $where_clauses[] = '(user_email LIKE %s OR user_name LIKE %s)';
2268 $where_params[] = $like;
2269 $where_params[] = $like;
2270 }
2271 $where_sql = 'WHERE ' . implode(' AND ', $where_clauses);
2272
2273 // Aggregate query grouped by the lead key (email, or session id when the
2274 // capture had no email). MAX(user_email) is safe as the display address:
2275 // rows sharing an email key all carry that same address, and rows keyed by
2276 // session have no address to disagree about.
2277 $select_sql = "SELECT {$lead_key_sql} AS lead_key, MAX(user_email) AS user_email,
2278 MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2279 COUNT(DISTINCT session_id) AS conversation_count";
2280
2281 $order_column = in_array($sort, ['last_seen', 'conversation_count', 'first_seen'], true) ? $sort : 'last_seen';
2282 $group_order_limit = " GROUP BY lead_key ORDER BY {$order_column} {$sort_dir} LIMIT %d OFFSET %d";
2283
2284 $transcripts_sql = $wpdb->prepare(
2285 "{$select_sql} FROM {$table} {$where_sql}{$group_order_limit}",
2286 array_merge($where_params, [$per_page, $offset])
2287 );
2288 $transcript_rows = $wpdb->get_results($transcripts_sql);
2289
2290 // Count of unique transcript-based leads under the same filters.
2291 // With no date / page / search filter there is nothing to bind: the base WHERE is
2292 // static and $lead_key_sql comes from mxchat_leads_key_sql(), so prepare() would
2293 // only raise "must have a placeholder" on every Leads tab open (plan 95b2da).
2294 $count_sql = "SELECT COUNT(DISTINCT {$lead_key_sql}) FROM {$table} {$where_sql}";
2295 if (!empty($where_params)) {
2296 $count_sql = $wpdb->prepare($count_sql, $where_params);
2297 }
2298 $transcripts_lead_count = (int) $wpdb->get_var($count_sql);
2299
2300 // Hydrate each row: name, latest_session_id, top page.
2301 $leads = [];
2302 foreach ($transcript_rows as $row) {
2303 $detail = $has_page_url_column
2304 ? $wpdb->get_row($wpdb->prepare(
2305 "SELECT session_id, user_name, originating_page_url, originating_page_title
2306 FROM {$table} WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2307 $row->lead_key
2308 ))
2309 : $wpdb->get_row($wpdb->prepare(
2310 "SELECT session_id, user_name FROM {$table}
2311 WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2312 $row->lead_key
2313 ));
2314
2315 $leads[] = [
2316 'lead_key' => $row->lead_key,
2317 'email' => (string) $row->user_email,
2318 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2319 'conversation_count' => (int) $row->conversation_count,
2320 'last_seen' => $row->last_seen,
2321 'last_seen_display' => self::mxchat_leads_format_relative($row->last_seen),
2322 'first_seen' => $row->first_seen,
2323 'latest_session_id' => isset($detail->session_id) ? $detail->session_id : '',
2324 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2325 'top_page_title' => isset($detail->originating_page_title) ? $detail->originating_page_title : '',
2326 'is_orphan' => false,
2327 'status' => 'active',
2328 ];
2329 }
2330
2331 // Non-transcript lead sources. Built once here, then filtered/merged based on the
2332 // status filter below. Deduplication priority when the same email appears in multiple
2333 // sources: transcripts > chat_deleted > orphan.
2334 $transcripts_emails_seen = array_flip(array_map(
2335 function ($r) { return self::mxchat_leads_dedup_key($r); },
2336 $leads
2337 ));
2338
2339 // Chat-deleted leads: had a conversation that an admin removed. Preserved via
2340 // mxchat_lead_del_* options, with timestamps so they respect date filters.
2341 $chat_deleted_leads_all = [];
2342 if ($status === 'all' || $status === 'chat_deleted') {
2343 $chat_deleted_leads_all = self::mxchat_collect_chat_deleted_leads($search, $date_cutoff);
2344 // Dedup: drop any chat_deleted row whose email is already in the transcripts set.
2345 $chat_deleted_leads_all = array_values(array_filter(
2346 $chat_deleted_leads_all,
2347 function ($row) use ($transcripts_emails_seen) {
2348 return !isset($transcripts_emails_seen[self::mxchat_leads_dedup_key($row)]);
2349 }
2350 ));
2351 foreach ($chat_deleted_leads_all as $row) {
2352 $transcripts_emails_seen[self::mxchat_leads_dedup_key($row)] = true;
2353 }
2354 }
2355
2356 // Orphans: pre-chat form captures with no conversation. No timestamp, so skipped
2357 // when a date filter is active.
2358 $orphan_leads_all = [];
2359 if (($status === 'all' || $status === 'orphan') && !$date_cutoff && !$page_filter) {
2360 $orphan_leads_all = self::mxchat_collect_orphan_leads($search);
2361 $orphan_leads_all = array_values(array_filter(
2362 $orphan_leads_all,
2363 function ($row) use ($transcripts_emails_seen) {
2364 return !isset($transcripts_emails_seen[self::mxchat_leads_dedup_key($row)]);
2365 }
2366 ));
2367 }
2368
2369 // Apply status filter to the transcripts-derived list.
2370 if ($status === 'orphan' || $status === 'chat_deleted') {
2371 $leads = [];
2372 $transcripts_lead_count = 0;
2373 }
2374
2375 // Stitch the current page from the three buckets in priority order.
2376 $total_count = $transcripts_lead_count + count($chat_deleted_leads_all) + count($orphan_leads_all);
2377 $remaining_slots = $per_page - count($leads);
2378
2379 if ($remaining_slots > 0 && !empty($chat_deleted_leads_all)) {
2380 $start = max(0, ($page - 1) * $per_page - $transcripts_lead_count);
2381 if ($start < count($chat_deleted_leads_all)) {
2382 $leads = array_merge($leads, array_slice($chat_deleted_leads_all, $start, $remaining_slots));
2383 $remaining_slots = $per_page - count($leads);
2384 }
2385 }
2386
2387 if ($remaining_slots > 0 && !empty($orphan_leads_all)) {
2388 $before = $transcripts_lead_count + count($chat_deleted_leads_all);
2389 $start = max(0, ($page - 1) * $per_page - $before);
2390 if ($start < count($orphan_leads_all)) {
2391 $leads = array_merge($leads, array_slice($orphan_leads_all, $start, $remaining_slots));
2392 }
2393 }
2394
2395 $total_pages = $per_page > 0 ? (int) ceil($total_count / $per_page) : 1;
2396
2397 // Stats strip: always computed over full dataset, unaffected by filters.
2398 $stats = self::mxchat_leads_stats($table, $has_page_url_column);
2399
2400 // Top pages: top 5 by distinct emails captured.
2401 $top_pages = [];
2402 if ($has_page_url_column) {
2403 $top_pages_rows = $wpdb->get_results(
2404 "SELECT originating_page_url AS url,
2405 MAX(originating_page_title) AS title,
2406 COUNT(DISTINCT user_email) AS lead_count
2407 FROM {$table}
2408 WHERE user_email IS NOT NULL AND user_email != ''
2409 AND originating_page_url IS NOT NULL AND originating_page_url != ''
2410 GROUP BY originating_page_url
2411 ORDER BY lead_count DESC, url ASC
2412 LIMIT 5"
2413 );
2414 foreach ($top_pages_rows as $p) {
2415 $top_pages[] = [
2416 'url' => $p->url,
2417 'title' => $p->title ?: $p->url,
2418 'lead_count' => (int) $p->lead_count,
2419 ];
2420 }
2421 }
2422
2423 // Consent state per lead (b062c4). Chat-deleted rows already carry it from
2424 // their preserved options; the rest read the newest session-store record
2425 // for that email. Empty string = NOT RECORDED (pre-feature capture or the
2426 // checkbox was off) — distinct from 'no', which is a recorded decline.
2427 $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent');
2428 foreach ($leads as &$lead_consent_ref) {
2429 if (array_key_exists('consent', $lead_consent_ref)) {
2430 continue;
2431 }
2432 $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($lead_consent_ref['email']) : null;
2433 $lead_consent_ref['consent'] = $consent ? $consent['given'] : '';
2434 $lead_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : '';
2435 $lead_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : '';
2436 }
2437 unset($lead_consent_ref);
2438
2439 return [
2440 'success' => true,
2441 'leads' => $leads,
2442 'page' => $page,
2443 'per_page' => $per_page,
2444 'total_count' => $total_count,
2445 'total_pages' => $total_pages,
2446 'showing_start' => $total_count === 0 ? 0 : ($offset + 1),
2447 'showing_end' => min($offset + $per_page, $total_count),
2448 'stats' => $stats,
2449 'top_pages' => $top_pages,
2450 ];
2451 }
2452
2453 /**
2454 * Delete one or more leads by email. Removes every transcripts row for that
2455 * email and cleans up related wp_options (mxchat_email_{sid}, mxchat_name_{sid},
2456 * mxchat_history_{sid}) and any orphan option entries matching the email.
2457 */
2458 public function mxchat_delete_leads() {
2459 if (!current_user_can('manage_options')) {
2460 wp_send_json_error(['message' => 'Insufficient permissions']);
2461 wp_die();
2462 }
2463 check_ajax_referer('mxchat_delete_leads', 'security');
2464
2465 // The tab sends back the key it was given, which is an address for most
2466 // leads and "session:<id>" for a name-only capture (c0cfaf). Splitting
2467 // here keeps sanitize_email() as the gate for the email half — an empty
2468 // or malformed value is dropped rather than turned into a WHERE clause
2469 // that would match every address-less row at once.
2470 $keys = self::mxchat_leads_split_keys(
2471 isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : []
2472 );
2473 $emails = $keys['emails'];
2474 $sessions = $keys['sessions'];
2475
2476 if (empty($emails) && empty($sessions)) {
2477 wp_send_json_error(['message' => 'No emails provided']);
2478 wp_die();
2479 }
2480
2481 $summary = ['deleted_sessions' => 0, 'deleted_rows' => 0];
2482 if (!empty($emails)) {
2483 $summary = self::mxchat_wipe_leads_by_email($emails);
2484 }
2485 if (!empty($sessions)) {
2486 $session_summary = self::mxchat_wipe_leads_by_session($sessions);
2487 $summary['deleted_sessions'] += $session_summary['deleted_sessions'];
2488 $summary['deleted_rows'] += $session_summary['deleted_rows'];
2489 }
2490
2491 wp_send_json([
2492 'success' => true,
2493 'deleted_leads' => count($emails) + count($sessions),
2494 'deleted_sessions' => $summary['deleted_sessions'],
2495 'deleted_rows' => $summary['deleted_rows'],
2496 ]);
2497 wp_die();
2498 }
2499
2500 /**
2501 * Wipe address-less leads by session id (c0cfaf) — the session-keyed twin of
2502 * mxchat_wipe_leads_by_email(). Same per-session option cleanup; no
2503 * email-keyed orphan sweep, because these leads have no address to sweep on.
2504 */
2505 private static function mxchat_wipe_leads_by_session(array $session_ids) {
2506 global $wpdb;
2507 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2508 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2509 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2510
2511 $deleted_sessions = 0;
2512 $deleted_rows = 0;
2513
2514 foreach ($session_ids as $sid) {
2515 if ($sid === '') {
2516 continue;
2517 }
2518
2519 $rows_removed = $wpdb->delete($table, ['session_id' => $sid], ['%s']);
2520 if ($rows_removed !== false) {
2521 $deleted_rows += (int) $rows_removed;
2522 }
2523
2524 $deleted_sessions++;
2525 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2526 delete_option('mxchat_history_' . $sid);
2527 delete_option('mxchat_email_' . $sid);
2528 delete_option('mxchat_name_' . $sid);
2529 delete_option('mxchat_agent_name_' . $sid);
2530 delete_option('mxchat_lead_del_email_' . $sid);
2531 delete_option('mxchat_lead_del_name_' . $sid);
2532 delete_option('mxchat_lead_del_ts_' . $sid);
2533 delete_option('mxchat_lead_del_consent_' . $sid);
2534 delete_option('mxchat_lead_del_consent_label_' . $sid);
2535 delete_option('mxchat_lead_del_consent_at_' . $sid);
2536 if (class_exists('MxChat_Session_Store')) {
2537 MxChat_Session_Store::delete_session($sid); // b64b77
2538 }
2539
2540 if ($has_translations) {
2541 $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2542 }
2543 }
2544
2545 return ['deleted_sessions' => $deleted_sessions, 'deleted_rows' => $deleted_rows];
2546 }
2547
2548 /**
2549 * Fully wipe one or more leads by email: every transcripts row, every related wp_options
2550 * entry (history, pre-chat capture, chat_deleted preservation, agent name, translations).
2551 *
2552 * Shared between the Leads-tab Delete button and the transcript-delete opt-in checkbox.
2553 * Input emails must already be sanitized with sanitize_email().
2554 */
2555 private static function mxchat_wipe_leads_by_email(array $emails) {
2556 global $wpdb;
2557 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2558 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2559 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2560
2561 $deleted_sessions = 0;
2562 $deleted_rows = 0;
2563 $emails_lc = array_map('strtolower', $emails);
2564
2565 foreach ($emails as $email) {
2566 $session_ids = $wpdb->get_col($wpdb->prepare(
2567 "SELECT DISTINCT session_id FROM {$table} WHERE user_email = %s",
2568 $email
2569 ));
2570
2571 $rows_removed = $wpdb->delete($table, ['user_email' => $email], ['%s']);
2572 if ($rows_removed !== false) {
2573 $deleted_rows += (int) $rows_removed;
2574 }
2575
2576 foreach ($session_ids as $sid) {
2577 $deleted_sessions++;
2578 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2579 delete_option('mxchat_history_' . $sid);
2580 delete_option('mxchat_email_' . $sid);
2581 delete_option('mxchat_name_' . $sid);
2582 delete_option('mxchat_agent_name_' . $sid);
2583 delete_option('mxchat_lead_del_email_' . $sid);
2584 delete_option('mxchat_lead_del_name_' . $sid);
2585 delete_option('mxchat_lead_del_ts_' . $sid);
2586 delete_option('mxchat_lead_del_consent_' . $sid);
2587 delete_option('mxchat_lead_del_consent_label_' . $sid);
2588 delete_option('mxchat_lead_del_consent_at_' . $sid);
2589 if (class_exists('MxChat_Session_Store')) {
2590 MxChat_Session_Store::delete_session($sid); // b64b77
2591 }
2592 if ($has_translations) {
2593 $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2594 }
2595 }
2596 }
2597
2598 // Table-side twin of the option sweep below (5658f2): orphan pre-chat
2599 // captures now live in the sessions table's identity columns.
2600 if (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'find_by_email')) {
2601 foreach ($emails as $email) {
2602 foreach (MxChat_Session_Store::find_by_email($email) as $sid) {
2603 MxChat_Session_Store::delete_session($sid);
2604 }
2605 }
2606 }
2607
2608 // Clean up any lingering option entries (unmigrated orphan pre-chat captures +
2609 // chat_deleted preservations) whose stored value matches one of the emails being wiped.
2610 $lingering = $wpdb->get_results(
2611 "SELECT option_name, option_value FROM {$wpdb->options}
2612 WHERE option_name LIKE 'mxchat_email_%' OR option_name LIKE 'mxchat_lead_del_email_%'"
2613 );
2614 foreach ($lingering as $opt) {
2615 if (!in_array(strtolower(trim($opt->option_value)), $emails_lc, true)) {
2616 continue;
2617 }
2618 if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
2619 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2620 delete_option('mxchat_lead_del_email_' . $sid);
2621 delete_option('mxchat_lead_del_name_' . $sid);
2622 delete_option('mxchat_lead_del_ts_' . $sid);
2623 delete_option('mxchat_lead_del_consent_' . $sid);
2624 delete_option('mxchat_lead_del_consent_label_' . $sid);
2625 delete_option('mxchat_lead_del_consent_at_' . $sid);
2626 } else {
2627 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2628 delete_option('mxchat_email_' . $sid);
2629 delete_option('mxchat_name_' . $sid);
2630 }
2631 }
2632
2633 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
2634
2635 return [
2636 'deleted_sessions' => $deleted_sessions,
2637 'deleted_rows' => $deleted_rows,
2638 ];
2639 }
2640
2641 /**
2642 * Stream a leads CSV. scope=all exports every lead under current filters is not
2643 * supported to keep semantics simple; caller either exports all leads or a
2644 * specific set of selected emails.
2645 */
2646 public function mxchat_export_leads() {
2647 if (!current_user_can('manage_options')) {
2648 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2649 }
2650 check_ajax_referer('mxchat_export_leads', 'security');
2651
2652 $scope = isset($_POST['scope']) ? sanitize_key($_POST['scope']) : 'all';
2653 $fields_mode = isset($_POST['fields']) ? sanitize_key($_POST['fields']) : 'email_and_name';
2654 // Selection keys arrive in the same shape the Leads tab renders them:
2655 // an address, or "session:<id>" for a name-only capture (c0cfaf).
2656 $selection_keys = self::mxchat_leads_split_keys(
2657 isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : []
2658 );
2659 $emails_in_clean = $selection_keys['emails'];
2660 $sessions_in = $selection_keys['sessions'];
2661
2662 global $wpdb;
2663 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2664 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2665
2666 // Collect leads from transcripts. Keyed by email, or by session id when
2667 // the capture had no address (c0cfaf) — gated on the option, so with email
2668 // required this is the same query and the same rows as before.
2669 $lead_key_sql = self::mxchat_leads_key_sql();
2670 $email_filter = self::mxchat_leads_email_optional()
2671 ? "((user_email IS NOT NULL AND user_email != '') OR (user_name IS NOT NULL AND user_name != ''))"
2672 : "user_email IS NOT NULL AND user_email != ''";
2673
2674 $transcripts_sql = "SELECT {$lead_key_sql} AS lead_key,
2675 MAX(user_email) AS email,
2676 MAX(timestamp) AS last_seen,
2677 COUNT(DISTINCT session_id) AS conversation_count
2678 FROM {$table}
2679 WHERE {$email_filter}";
2680 $params = [];
2681 $selected_keys = array_merge(
2682 $emails_in_clean,
2683 array_map(function ($sid) { return 'session:' . $sid; }, $sessions_in)
2684 );
2685 if ($scope === 'selected' && !empty($selected_keys)) {
2686 $placeholders = implode(',', array_fill(0, count($selected_keys), '%s'));
2687 $transcripts_sql .= " AND {$lead_key_sql} IN ({$placeholders})";
2688 $params = $selected_keys;
2689 }
2690 $transcripts_sql .= " GROUP BY lead_key ORDER BY last_seen DESC";
2691
2692 $rows = !empty($params)
2693 ? $wpdb->get_results($wpdb->prepare($transcripts_sql, $params))
2694 : $wpdb->get_results($transcripts_sql);
2695
2696 // Hydrate each row with name + top page.
2697 $export_rows = [];
2698 foreach ($rows as $row) {
2699 $detail = $has_page_url_column
2700 ? $wpdb->get_row($wpdb->prepare(
2701 "SELECT user_name, originating_page_url FROM {$table}
2702 WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2703 $row->lead_key
2704 ))
2705 : $wpdb->get_row($wpdb->prepare(
2706 "SELECT user_name FROM {$table}
2707 WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2708 $row->lead_key
2709 ));
2710 $export_rows[] = [
2711 'email' => (string) $row->email,
2712 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2713 'conversation_count' => (int) $row->conversation_count,
2714 'last_seen' => $row->last_seen,
2715 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2716 ];
2717 }
2718
2719 // Include orphan + chat_deleted leads when exporting all.
2720 if ($scope === 'all') {
2721 $transcripts_emails_lc = array_flip(array_map(
2722 function ($r) { return strtolower($r['email']); },
2723 $export_rows
2724 ));
2725 foreach (self::mxchat_collect_chat_deleted_leads('') as $cd) {
2726 if (isset($transcripts_emails_lc[strtolower($cd['email'])])) continue;
2727 $transcripts_emails_lc[strtolower($cd['email'])] = true;
2728 $export_rows[] = [
2729 'email' => $cd['email'],
2730 'name' => $cd['name'],
2731 'conversation_count' => 0,
2732 'last_seen' => $cd['last_seen'],
2733 'top_page_url' => '',
2734 'consent' => isset($cd['consent']) ? $cd['consent'] : '',
2735 'consent_at' => isset($cd['consent_at']) ? $cd['consent_at'] : '',
2736 'consent_label' => isset($cd['consent_label']) ? $cd['consent_label'] : '',
2737 ];
2738 }
2739 foreach (self::mxchat_collect_orphan_leads('') as $orphan) {
2740 if (isset($transcripts_emails_lc[strtolower($orphan['email'])])) continue;
2741 $transcripts_emails_lc[strtolower($orphan['email'])] = true;
2742 $export_rows[] = [
2743 'email' => $orphan['email'],
2744 'name' => $orphan['name'],
2745 'conversation_count' => 0,
2746 'last_seen' => '',
2747 'top_page_url' => '',
2748 ];
2749 }
2750 }
2751
2752 if (empty($export_rows)) {
2753 wp_send_json_error(['message' => 'No leads to export.']);
2754 wp_die();
2755 }
2756
2757 // Consent columns (b062c4). Chat-deleted rows already carry the preserved
2758 // values; the rest read the newest session-store record for that email.
2759 // Empty consent = "not recorded" — a pre-feature capture, NOT a decline.
2760 $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent');
2761 foreach ($export_rows as &$export_consent_ref) {
2762 if (array_key_exists('consent', $export_consent_ref)) {
2763 continue;
2764 }
2765 $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($export_consent_ref['email']) : null;
2766 $export_consent_ref['consent'] = $consent ? $consent['given'] : '';
2767 $export_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : '';
2768 $export_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : '';
2769 }
2770 unset($export_consent_ref);
2771
2772 $filename = 'mxchat-leads-' . date('Y-m-d') . '.csv';
2773 header('Content-Type: text/csv');
2774 header('Content-Disposition: attachment; filename="' . $filename . '"');
2775 header('Pragma: no-cache');
2776 header('Expires: 0');
2777
2778 $output = fopen('php://output', 'w');
2779 fputs($output, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel
2780
2781 if ($fields_mode === 'email_only') {
2782 fputcsv($output, ['Email']);
2783 foreach ($export_rows as $r) {
2784 fputcsv($output, [$r['email']]);
2785 }
2786 } else {
2787 fputcsv($output, ['Email', 'Name', 'Conversations', 'Last seen', 'Top page', 'Consent', 'Consent recorded', 'Consent text shown']);
2788 foreach ($export_rows as $r) {
2789 fputcsv($output, [
2790 $r['email'],
2791 $r['name'],
2792 $r['conversation_count'],
2793 $r['last_seen'],
2794 $r['top_page_url'],
2795 $r['consent'] !== '' ? $r['consent'] : 'not recorded',
2796 $r['consent_at'],
2797 $r['consent_label'],
2798 ]);
2799 }
2800 }
2801
2802 fclose($output);
2803 wp_die();
2804 }
2805
2806 /**
2807 * Stats strip payload (independent of filters).
2808 */
2809 private static function mxchat_leads_stats($table, $has_page_url_column) {
2810 global $wpdb;
2811
2812 $total_transcripts_emails = (int) $wpdb->get_var(
2813 "SELECT COUNT(DISTINCT user_email) FROM {$table}
2814 WHERE user_email IS NOT NULL AND user_email != ''"
2815 );
2816
2817 $new_this_week = (int) $wpdb->get_var($wpdb->prepare(
2818 "SELECT COUNT(*) FROM (
2819 SELECT user_email FROM {$table}
2820 WHERE user_email IS NOT NULL AND user_email != ''
2821 GROUP BY user_email
2822 HAVING MIN(timestamp) >= %s
2823 ) AS new_leads",
2824 gmdate('Y-m-d H:i:s', strtotime('-7 days'))
2825 ));
2826
2827 $total_convos = (int) $wpdb->get_var(
2828 "SELECT COUNT(DISTINCT session_id) FROM {$table}
2829 WHERE user_email IS NOT NULL AND user_email != ''"
2830 );
2831
2832 $orphan_count = count(self::mxchat_collect_orphan_leads(''));
2833 $chat_deleted_count = self::mxchat_count_chat_deleted_leads();
2834
2835 // Total leads = unique emails across all three sources (dedup priority: transcripts > chat_deleted > orphan
2836 // is already enforced at collection time in mxchat_fetch_leads; stats re-apply it here).
2837 $total_leads = $total_transcripts_emails + $chat_deleted_count + $orphan_count;
2838
2839 $avg = $total_transcripts_emails > 0
2840 ? round($total_convos / $total_transcripts_emails, 1)
2841 : 0;
2842
2843 // Orphan % reflects *true* orphans only (pre-chat dropoffs). Chat-deleted leads are
2844 // excluded so the metric stays meaningful — admins shouldn't see their cleanups
2845 // inflate this number.
2846 $orphan_pct = $total_leads > 0
2847 ? (int) round(($orphan_count / $total_leads) * 100)
2848 : 0;
2849
2850 return [
2851 'total_leads' => $total_leads,
2852 'new_this_week' => $new_this_week,
2853 'avg_convos' => $avg,
2854 'orphan_pct' => $orphan_pct,
2855 'orphan_count' => $orphan_count,
2856 'chat_deleted_count' => $chat_deleted_count,
2857 ];
2858 }
2859
2860 /**
2861 * Collect leads who had a conversation that an admin later deleted (preserved via
2862 * mxchat_lead_del_* options). Returns rows tagged status='chat_deleted' with the
2863 * original last-seen timestamp so they still sort and filter sensibly.
2864 *
2865 * @param string $search Optional email/name substring filter.
2866 * @param string $date_cutoff Optional 'Y-m-d H:i:s' cutoff — only rows with last_ts >= cutoff.
2867 * @return array
2868 */
2869 private static function mxchat_collect_chat_deleted_leads($search = '', $date_cutoff = '') {
2870 global $wpdb;
2871 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2872
2873 $rows = $wpdb->get_results(
2874 "SELECT option_name, option_value FROM {$wpdb->options}
2875 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2876 );
2877 if (empty($rows)) {
2878 return [];
2879 }
2880
2881 // Emails that currently have transcripts rows should not appear as chat_deleted —
2882 // they've come back and chatted, so they're active leads again.
2883 $emails_in_transcripts = array_map(
2884 'strtolower',
2885 (array) $wpdb->get_col(
2886 "SELECT DISTINCT user_email FROM {$table}
2887 WHERE user_email IS NOT NULL AND user_email != ''"
2888 )
2889 );
2890 $emails_in_transcripts = array_flip($emails_in_transcripts);
2891
2892 $needle = strtolower(trim((string) $search));
2893 $by_email = [];
2894
2895 foreach ($rows as $opt) {
2896 $email = sanitize_email(trim((string) $opt->option_value));
2897 if (!$email) {
2898 continue;
2899 }
2900 if (isset($emails_in_transcripts[strtolower($email)])) {
2901 continue;
2902 }
2903 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2904 if (!$sid) {
2905 continue;
2906 }
2907 $name = (string) get_option('mxchat_lead_del_name_' . $sid, '');
2908 $ts = (string) get_option('mxchat_lead_del_ts_' . $sid, '');
2909
2910 if ($date_cutoff !== '' && ($ts === '' || $ts < $date_cutoff)) {
2911 continue;
2912 }
2913 if ($needle !== '') {
2914 $hay = strtolower($email . ' ' . $name);
2915 if (strpos($hay, $needle) === false) {
2916 continue;
2917 }
2918 }
2919
2920 $key = strtolower($email);
2921 if (!isset($by_email[$key]) || (isset($by_email[$key]['last_seen']) && $ts > $by_email[$key]['last_seen'])) {
2922 // Consent proof preserved at chat-delete time (b062c4) — the
2923 // session row is gone, so these options are the only copy.
2924 $consent_state = (string) get_option('mxchat_lead_del_consent_' . $sid, '');
2925
2926 $by_email[$key] = [
2927 'email' => $email,
2928 'name' => $name,
2929 'conversation_count' => 0,
2930 'last_seen' => $ts,
2931 'last_seen_display' => $ts ? self::mxchat_leads_format_relative($ts) : __('Chat deleted', 'mxchat'),
2932 'first_seen' => $ts,
2933 'latest_session_id' => '',
2934 'top_page_url' => '',
2935 'top_page_title' => '',
2936 'is_orphan' => false,
2937 'status' => 'chat_deleted',
2938 'consent' => $consent_state,
2939 'consent_at' => $consent_state !== '' ? (string) get_option('mxchat_lead_del_consent_at_' . $sid, '') : '',
2940 'consent_label' => $consent_state !== '' ? wp_strip_all_tags((string) get_option('mxchat_lead_del_consent_label_' . $sid, '')) : '',
2941 ];
2942 }
2943 }
2944
2945 // Newest chat_deleted first.
2946 usort($by_email, function ($a, $b) {
2947 return strcmp((string) $b['last_seen'], (string) $a['last_seen']);
2948 });
2949 return array_values($by_email);
2950 }
2951
2952 /**
2953 * Count unique emails preserved as "chat deleted" (for the stats strip).
2954 */
2955 private static function mxchat_count_chat_deleted_leads() {
2956 global $wpdb;
2957 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2958
2959 $emails = $wpdb->get_col(
2960 "SELECT DISTINCT option_value FROM {$wpdb->options}
2961 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2962 );
2963 if (empty($emails)) {
2964 return 0;
2965 }
2966
2967 $transcripts_emails = array_map(
2968 'strtolower',
2969 (array) $wpdb->get_col(
2970 "SELECT DISTINCT user_email FROM {$table}
2971 WHERE user_email IS NOT NULL AND user_email != ''"
2972 )
2973 );
2974 $transcripts_emails = array_flip($transcripts_emails);
2975
2976 $count = 0;
2977 $seen = [];
2978 foreach ($emails as $raw) {
2979 $email = strtolower(trim((string) $raw));
2980 if (!$email || isset($seen[$email]) || isset($transcripts_emails[$email])) {
2981 continue;
2982 }
2983 $seen[$email] = true;
2984 $count++;
2985 }
2986 return $count;
2987 }
2988
2989 /**
2990 * Find leads who submitted the pre-chat form but never produced a transcripts row.
2991 * Returned rows have no conversation_count, no timestamp.
2992 *
2993 * @param string $search Optional email/name substring filter.
2994 * @return array
2995 */
2996 private static function mxchat_collect_orphan_leads($search = '') {
2997 global $wpdb;
2998 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2999
3000 // Primary source since 5658f2: the sessions table's identity columns.
3001 // The legacy option scan stays for installs still mid-migration.
3002 // Ask for name-only sessions too when the owner made email optional
3003 // (c0cfaf) — that is the only source an address-less lead has.
3004 $store_rows = (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'identity_rows'))
3005 ? MxChat_Session_Store::identity_rows(self::mxchat_leads_email_optional())
3006 : [];
3007
3008 $option_rows = $wpdb->get_results(
3009 "SELECT option_name, option_value FROM {$wpdb->options}
3010 WHERE option_name LIKE 'mxchat_email_%'"
3011 );
3012 if (empty($option_rows) && empty($store_rows)) {
3013 return [];
3014 }
3015
3016 // Collect all session_ids that have real transcripts rows so we can exclude them.
3017 $session_ids_with_rows = $wpdb->get_col(
3018 "SELECT DISTINCT session_id FROM {$table}
3019 WHERE user_email IS NOT NULL AND user_email != ''"
3020 );
3021 $session_ids_with_rows = array_flip($session_ids_with_rows);
3022
3023 // Seen emails in transcripts (so orphans only include truly never-chatted leads).
3024 $emails_in_transcripts = array_map(
3025 'strtolower',
3026 (array) $wpdb->get_col(
3027 "SELECT DISTINCT user_email FROM {$table}
3028 WHERE user_email IS NOT NULL AND user_email != ''"
3029 )
3030 );
3031 $emails_in_transcripts = array_flip($emails_in_transcripts);
3032
3033 $orphans_by_email = [];
3034 $needle = strtolower(trim((string) $search));
3035
3036 // Normalize both sources into [sid, email, name] candidates. Table rows
3037 // first — a session can only live in one home, and dedup-by-email below
3038 // handles any overlap either way.
3039 $candidates = [];
3040 foreach ($store_rows as $row) {
3041 $candidates[] = [
3042 'sid' => (string) $row['session_id'],
3043 'email' => (string) $row['visitor_email'],
3044 'name' => is_string($row['visitor_name'] ?? null) ? $row['visitor_name'] : '',
3045 ];
3046 }
3047 foreach ($option_rows as $opt) {
3048 $sid = substr($opt->option_name, strlen('mxchat_email_'));
3049 $name_option = $sid ? get_option('mxchat_name_' . $sid, '') : '';
3050 $candidates[] = [
3051 'sid' => (string) $sid,
3052 'email' => (string) $opt->option_value,
3053 'name' => is_string($name_option) ? $name_option : '',
3054 ];
3055 }
3056
3057 // A capture with no address is an orphan too, and the commonest one: a
3058 // name-only lead (c0cfaf) has no transcript row until the visitor actually
3059 // sends a message, so this collector is the ONLY place it can surface.
3060 // Keying it on email would drop it silently — collected, then invisible.
3061 $email_optional = self::mxchat_leads_email_optional();
3062
3063 foreach ($candidates as $cand) {
3064 $email = sanitize_email(trim($cand['email']));
3065 $name = trim($cand['name']);
3066 $sid = $cand['sid'];
3067 if (!$sid) {
3068 continue;
3069 }
3070 if (!$email) {
3071 // Address-less rows are only leads when the owner made email
3072 // optional; otherwise they are incomplete captures, and skipping
3073 // them keeps this list identical to what it has always shown.
3074 if (!$email_optional || $name === '') {
3075 continue;
3076 }
3077 }
3078 // Exclude leads who have any transcripts rows (they appear in the main list).
3079 if ($email && isset($emails_in_transcripts[strtolower($email)])) {
3080 continue;
3081 }
3082 if (isset($session_ids_with_rows[$sid])) {
3083 continue;
3084 }
3085
3086 if ($needle !== '') {
3087 $hay = strtolower($email . ' ' . $name);
3088 if (strpos($hay, $needle) === false) {
3089 continue;
3090 }
3091 }
3092
3093 $key = $email !== '' ? strtolower($email) : 'session:' . $sid;
3094 if (!isset($orphans_by_email[$key])) {
3095 $orphans_by_email[$key] = [
3096 'lead_key' => $key,
3097 'email' => $email,
3098 'name' => $name,
3099 'conversation_count' => 0,
3100 'last_seen' => '',
3101 'last_seen_display' => __('No conversation yet', 'mxchat'),
3102 'first_seen' => '',
3103 // An address-less orphan carries its session id: that is the
3104 // only handle the delete and export paths have on it. Rows
3105 // that DO have an address keep the empty value they always
3106 // had, so the "View convo" button stays hidden for them.
3107 'latest_session_id' => $email !== '' ? '' : $sid,
3108 'top_page_url' => '',
3109 'top_page_title' => '',
3110 'is_orphan' => true,
3111 'status' => 'orphan',
3112 ];
3113 }
3114 }
3115
3116 return array_values($orphans_by_email);
3117 }
3118
3119 /**
3120 * Is the pre-chat email field optional? (c0cfaf)
3121 *
3122 * When it is, a lead can exist with a name and no address, and every place
3123 * that treats email as the row key has to widen. Default ON — an install that
3124 * never touched the setting behaves exactly as it did before.
3125 */
3126 private static function mxchat_leads_email_optional() {
3127 $options = get_option('mxchat_options', []);
3128 return is_array($options) && ($options['lead_capture_require_email_toggle'] ?? 'on') !== 'on';
3129 }
3130
3131 /**
3132 * SQL expression for a lead's key: its email address, or "session:<id>" when
3133 * the capture had no address (c0cfaf).
3134 *
3135 * The literal prefix is what keeps the two key spaces apart — an address can
3136 * never contain a colon before its local part, so a session key can never be
3137 * mistaken for an email or vice versa. NULLIF collapses '' and NULL to the
3138 * same thing, since the column uses both for "no email given".
3139 *
3140 * Contains no user input: safe to interpolate into a prepared statement.
3141 */
3142 private static function mxchat_leads_key_sql() {
3143 return "COALESCE(NULLIF(user_email, ''), CONCAT('session:', session_id))";
3144 }
3145
3146 /**
3147 * The PHP-side twin of mxchat_leads_key_sql(), used to dedupe rows arriving
3148 * from the three lead sources. Email keys stay lowercased, exactly as before,
3149 * so dedup behaviour for addressed leads is unchanged.
3150 */
3151 private static function mxchat_leads_dedup_key($row) {
3152 $email = isset($row['email']) ? trim((string) $row['email']) : '';
3153 if ($email !== '') {
3154 return strtolower($email);
3155 }
3156 if (!empty($row['lead_key'])) {
3157 return (string) $row['lead_key'];
3158 }
3159 return 'session:' . (isset($row['latest_session_id']) ? (string) $row['latest_session_id'] : '');
3160 }
3161
3162 /**
3163 * Split a list of client-supplied lead keys into real email addresses and
3164 * session keys. The Leads tab sends back whatever key it was given, so both
3165 * shapes arrive on the same parameter (c0cfaf).
3166 *
3167 * Anything that is neither a valid address nor a well-formed session key is
3168 * dropped — in particular an empty string, which as a bare email would match
3169 * every address-less row in the table at once.
3170 */
3171 private static function mxchat_leads_split_keys(array $raw) {
3172 $emails = [];
3173 $sessions = [];
3174 foreach ($raw as $value) {
3175 $value = trim((string) $value);
3176 if ($value === '') {
3177 continue;
3178 }
3179 if (strpos($value, 'session:') === 0) {
3180 $sid = MxChat_Utils::sanitize_session_id(substr($value, 8));
3181 if ($sid !== '' && $sid !== 'null') {
3182 $sessions[] = $sid;
3183 }
3184 continue;
3185 }
3186 $clean = sanitize_email($value);
3187 if ($clean) {
3188 $emails[] = $clean;
3189 }
3190 }
3191 return ['emails' => array_values(array_unique($emails)), 'sessions' => array_values(array_unique($sessions))];
3192 }
3193
3194 /**
3195 * Map a date_range key to a SQL-comparable cutoff string, or '' for all-time.
3196 */
3197 private static function mxchat_leads_date_cutoff($date_range) {
3198 switch ($date_range) {
3199 case 'today': return gmdate('Y-m-d H:i:s', strtotime('-24 hours'));
3200 case '7d': return gmdate('Y-m-d H:i:s', strtotime('-7 days'));
3201 case '30d': return gmdate('Y-m-d H:i:s', strtotime('-30 days'));
3202 case '90d': return gmdate('Y-m-d H:i:s', strtotime('-90 days'));
3203 case 'all':
3204 default: return '';
3205 }
3206 }
3207
3208 /**
3209 * Turn a UTC timestamp into a short relative display like "2h ago" or "Apr 12".
3210 */
3211 private static function mxchat_leads_format_relative($timestamp) {
3212 if (!$timestamp) {
3213 return '';
3214 }
3215 $ts = strtotime($timestamp . ' UTC');
3216 if (!$ts) {
3217 return '';
3218 }
3219 $diff = time() - $ts;
3220 if ($diff < 60) return __('just now', 'mxchat');
3221 if ($diff < 3600) return floor($diff / 60) . __('m ago', 'mxchat');
3222 if ($diff < 86400) return floor($diff / 3600) . __('h ago', 'mxchat');
3223 if ($diff < 604800) return floor($diff / 86400) . __('d ago', 'mxchat');
3224 return wp_date('M j', $ts);
3225 }
3226
3227 /**
3228 * Handle translation of chat messages via AJAX
3229 */
3230 public function mxchat_translate_messages() {
3231 if (!current_user_can('manage_options')) {
3232 wp_send_json_error(['error' => 'Insufficient permissions']);
3233 wp_die();
3234 }
3235
3236 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3237 $target_lang = isset($_POST['target_lang']) ? sanitize_text_field($_POST['target_lang']) : 'en';
3238 $messages_json = isset($_POST['messages']) ? wp_unslash($_POST['messages']) : '[]';
3239 $messages = json_decode($messages_json, true);
3240
3241 if (empty($session_id)) {
3242 wp_send_json_error(['error' => 'No session ID provided']);
3243 wp_die();
3244 }
3245
3246 if (empty($messages) || !is_array($messages)) {
3247 wp_send_json_error(['error' => 'No messages to translate']);
3248 wp_die();
3249 }
3250
3251 // Language names for prompting
3252 $languages = [
3253 'en' => 'English',
3254 'es' => 'Spanish',
3255 'fr' => 'French',
3256 'de' => 'German',
3257 'it' => 'Italian',
3258 'pt' => 'Portuguese',
3259 'nl' => 'Dutch',
3260 'ru' => 'Russian',
3261 'zh' => 'Chinese',
3262 'ja' => 'Japanese',
3263 'ko' => 'Korean',
3264 'ar' => 'Arabic',
3265 'hi' => 'Hindi',
3266 'tr' => 'Turkish',
3267 'pl' => 'Polish',
3268 'vi' => 'Vietnamese',
3269 'th' => 'Thai',
3270 'id' => 'Indonesian',
3271 'sv' => 'Swedish',
3272 'da' => 'Danish'
3273 ];
3274
3275 $target_lang_name = isset($languages[$target_lang]) ? $languages[$target_lang] : 'English';
3276
3277 // Build combined text for translation (numbered for parsing)
3278 $numbered_messages = [];
3279 foreach ($messages as $i => $msg) {
3280 $content = isset($msg['content']) ? trim($msg['content']) : '';
3281 if (!empty($content)) {
3282 $numbered_messages[] = "[MSG" . $i . "]" . $content . "[/MSG" . $i . "]";
3283 }
3284 }
3285
3286 if (empty($numbered_messages)) {
3287 wp_send_json_error(['error' => 'No valid messages to translate']);
3288 wp_die();
3289 }
3290
3291 $combined_text = implode("\n\n", $numbered_messages);
3292
3293 // Prepare the translation prompt
3294 $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.";
3295
3296 // Get user's selected model and determine provider
3297 $options = get_option('mxchat_options', []);
3298 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
3299
3300 // Check if using OpenRouter
3301 if ($selected_model === 'openrouter') {
3302 $provider = 'openrouter';
3303 $selected_model = $options['openrouter_selected_model'] ?? '';
3304 $api_key = $options['openrouter_api_key'] ?? '';
3305
3306 if (empty($selected_model)) {
3307 wp_send_json_error(['error' => 'No OpenRouter model selected']);
3308 wp_die();
3309 }
3310 } else {
3311 // Determine provider from model name
3312 $model_parts = explode('-', $selected_model);
3313 $provider = strtolower($model_parts[0]);
3314
3315 // Get the appropriate API key based on provider
3316 $api_key = '';
3317 switch ($provider) {
3318 case 'gpt':
3319 case 'o1':
3320 $api_key = $options['api_key'] ?? '';
3321 break;
3322 case 'claude':
3323 $api_key = $options['claude_api_key'] ?? '';
3324 break;
3325 case 'grok':
3326 $api_key = $options['xai_api_key'] ?? '';
3327 break;
3328 case 'deepseek':
3329 $api_key = $options['deepseek_api_key'] ?? '';
3330 break;
3331 case 'gemini':
3332 $api_key = $options['gemini_api_key'] ?? '';
3333 break;
3334 default:
3335 // Default to OpenAI
3336 $api_key = $options['api_key'] ?? '';
3337 $provider = 'gpt';
3338 break;
3339 }
3340 }
3341
3342 if (empty($api_key)) {
3343 wp_send_json_error(['error' => 'No API key configured for ' . $provider]);
3344 wp_die();
3345 }
3346
3347 // Make API request based on provider
3348 $response = $this->translate_with_provider($provider, $selected_model, $api_key, $system_prompt, $combined_text);
3349
3350 if (is_wp_error($response)) {
3351 wp_send_json_error(['error' => $response->get_error_message()]);
3352 wp_die();
3353 }
3354
3355 // Parse the response to extract translated messages
3356 $translations = [];
3357 foreach ($messages as $msg) {
3358 $index = $msg['index'];
3359 $pattern = '/\[MSG' . $index . '\](.*?)\[\/MSG' . $index . '\]/s';
3360 if (preg_match($pattern, $response, $matches)) {
3361 $translations[] = [
3362 'index' => $index,
3363 'translated' => trim($matches[1])
3364 ];
3365 }
3366 }
3367
3368 // Save translations to database
3369 if (!empty($translations)) {
3370 $this->save_transcript_translation($session_id, $target_lang, $translations);
3371 }
3372
3373 wp_send_json(['success' => true, 'translations' => $translations, 'language' => $target_lang]);
3374 wp_die();
3375 }
3376
3377 /**
3378 * Save transcript translation to database
3379 */
3380 private function save_transcript_translation($session_id, $language_code, $translations) {
3381 global $wpdb;
3382 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
3383
3384 // Check if table exists, create if not
3385 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
3386 mxchat_create_translations_table();
3387 }
3388
3389 $now = current_time('mysql');
3390 $translations_json = wp_json_encode($translations);
3391
3392 // Use REPLACE to insert or update
3393 $wpdb->query($wpdb->prepare(
3394 "REPLACE INTO $table_name (session_id, language_code, translations, created_at, updated_at)
3395 VALUES (%s, %s, %s, %s, %s)",
3396 $session_id,
3397 $language_code,
3398 $translations_json,
3399 $now,
3400 $now
3401 ));
3402 }
3403
3404 /**
3405 * Get saved translation for a session
3406 */
3407 public function mxchat_get_transcript_translation() {
3408 if (!current_user_can('manage_options')) {
3409 wp_send_json_error(['error' => 'Insufficient permissions']);
3410 wp_die();
3411 }
3412
3413 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3414
3415 if (empty($session_id)) {
3416 wp_send_json_error(['error' => 'No session ID provided']);
3417 wp_die();
3418 }
3419
3420 global $wpdb;
3421 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
3422
3423 // Check if table exists
3424 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
3425 wp_send_json(['success' => true, 'has_translation' => false]);
3426 wp_die();
3427 }
3428
3429 // Get the most recent translation for this session
3430 $result = $wpdb->get_row($wpdb->prepare(
3431 "SELECT language_code, translations FROM $table_name WHERE session_id = %s ORDER BY updated_at DESC LIMIT 1",
3432 $session_id
3433 ));
3434
3435 if ($result) {
3436 $translations = json_decode($result->translations, true);
3437 wp_send_json([
3438 'success' => true,
3439 'has_translation' => true,
3440 'language' => $result->language_code,
3441 'translations' => $translations
3442 ]);
3443 } else {
3444 wp_send_json(['success' => true, 'has_translation' => false]);
3445 }
3446 wp_die();
3447 }
3448
3449 /**
3450 * Translate text using the user's selected provider and model
3451 */
3452 private function translate_with_provider($provider, $model, $api_key, $system_prompt, $text) {
3453 switch ($provider) {
3454 case 'claude':
3455 return $this->translate_with_claude($api_key, $model, $system_prompt, $text);
3456 case 'grok':
3457 return $this->translate_with_xai($api_key, $model, $system_prompt, $text);
3458 case 'deepseek':
3459 return $this->translate_with_deepseek($api_key, $model, $system_prompt, $text);
3460 case 'gemini':
3461 return $this->translate_with_gemini($api_key, $model, $system_prompt, $text);
3462 case 'openrouter':
3463 return $this->translate_with_openrouter($api_key, $model, $system_prompt, $text);
3464 case 'gpt':
3465 case 'o1':
3466 default:
3467 return $this->translate_with_openai($api_key, $model, $system_prompt, $text);
3468 }
3469 }
3470
3471 /**
3472 * Translate text using OpenAI API
3473 */
3474 private function translate_with_openai($api_key, $model, $system_prompt, $text) {
3475 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
3476 'timeout' => 60,
3477 'headers' => [
3478 'Authorization' => 'Bearer ' . $api_key,
3479 'Content-Type' => 'application/json'
3480 ],
3481 'body' => wp_json_encode([
3482 'model' => $model,
3483 'messages' => [
3484 ['role' => 'system', 'content' => $system_prompt],
3485 ['role' => 'user', 'content' => $text]
3486 ],
3487 'temperature' => 0.3
3488 ])
3489 ]);
3490
3491 if (is_wp_error($response)) {
3492 return $response;
3493 }
3494
3495 $body = json_decode(wp_remote_retrieve_body($response), true);
3496
3497 if (isset($body['error'])) {
3498 return new WP_Error('api_error', $body['error']['message']);
3499 }
3500
3501 if (isset($body['choices'][0]['message']['content'])) {
3502 return $body['choices'][0]['message']['content'];
3503 }
3504
3505 return new WP_Error('api_error', 'Invalid API response');
3506 }
3507
3508 /**
3509 * Translate text using Claude API
3510 */
3511 private function translate_with_claude($api_key, $model, $system_prompt, $text) {
3512 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3513 // Read-time rescue: a saved retired id is remapped to the catalog's replacement before the API call.
3514 $model = MxChat_Model_Catalog::resolve_retired($model); // plan 8c4eb4: the catalog's one map
3515 $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
3516 'timeout' => 60,
3517 'headers' => [
3518 'x-api-key' => $api_key,
3519 'anthropic-version' => '2023-06-01',
3520 'Content-Type' => 'application/json'
3521 ],
3522 'body' => wp_json_encode([
3523 'model' => $model,
3524 'max_tokens' => 4096,
3525 // Prompt-cache breakpoint (plan 1ff43b): the translate instruction
3526 // repeats across every message in a batch, so cache reads bill at
3527 // 0.1x. Below the model's minimum prefix it silently no-ops.
3528 'system' => trim((string) $system_prompt) === '' ? $system_prompt : [
3529 ['type' => 'text', 'text' => $system_prompt, 'cache_control' => ['type' => 'ephemeral']]
3530 ],
3531 'messages' => [
3532 ['role' => 'user', 'content' => $text]
3533 ]
3534 ])
3535 ]);
3536
3537 if (is_wp_error($response)) {
3538 return $response;
3539 }
3540
3541 $body = json_decode(wp_remote_retrieve_body($response), true);
3542
3543 if (isset($body['error'])) {
3544 return new WP_Error('api_error', $body['error']['message']);
3545 }
3546
3547 if (isset($body['content'][0]['text'])) {
3548 return $body['content'][0]['text'];
3549 }
3550
3551 return new WP_Error('api_error', 'Invalid API response');
3552 }
3553
3554 /**
3555 * Translate text using xAI (Grok) API
3556 */
3557 private function translate_with_xai($api_key, $model, $system_prompt, $text) {
3558 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', [
3559 'timeout' => 60,
3560 'headers' => [
3561 'Authorization' => 'Bearer ' . $api_key,
3562 'Content-Type' => 'application/json'
3563 ],
3564 'body' => wp_json_encode([
3565 'model' => $model,
3566 'messages' => [
3567 ['role' => 'system', 'content' => $system_prompt],
3568 ['role' => 'user', 'content' => $text]
3569 ],
3570 'temperature' => 0.3
3571 ])
3572 ]);
3573
3574 if (is_wp_error($response)) {
3575 return $response;
3576 }
3577
3578 $body = json_decode(wp_remote_retrieve_body($response), true);
3579
3580 if (isset($body['error'])) {
3581 return new WP_Error('api_error', $body['error']['message']);
3582 }
3583
3584 if (isset($body['choices'][0]['message']['content'])) {
3585 return $body['choices'][0]['message']['content'];
3586 }
3587
3588 return new WP_Error('api_error', 'Invalid API response');
3589 }
3590
3591 /**
3592 * Translate text using DeepSeek API
3593 */
3594 private function translate_with_deepseek($api_key, $model, $system_prompt, $text) {
3595 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', [
3596 'timeout' => 60,
3597 'headers' => [
3598 'Authorization' => 'Bearer ' . $api_key,
3599 'Content-Type' => 'application/json'
3600 ],
3601 'body' => wp_json_encode([
3602 'model' => $model,
3603 'messages' => [
3604 ['role' => 'system', 'content' => $system_prompt],
3605 ['role' => 'user', 'content' => $text]
3606 ],
3607 'temperature' => 0.3,
3608 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3609 // slow responses); translation wants non-thinking.
3610 'thinking' => ['type' => 'disabled']
3611 ])
3612 ]);
3613
3614 if (is_wp_error($response)) {
3615 return $response;
3616 }
3617
3618 $body = json_decode(wp_remote_retrieve_body($response), true);
3619
3620 if (isset($body['error'])) {
3621 return new WP_Error('api_error', $body['error']['message']);
3622 }
3623
3624 if (isset($body['choices'][0]['message']['content'])) {
3625 return $body['choices'][0]['message']['content'];
3626 }
3627
3628 return new WP_Error('api_error', 'Invalid API response');
3629 }
3630
3631 /**
3632 * Translate text using Google Gemini API
3633 */
3634 private function translate_with_gemini($api_key, $model, $system_prompt, $text) {
3635 if ($model === 'gemini-3-pro-preview') {
3636 $model = 'gemini-3.1-pro-preview';
3637 }
3638 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
3639
3640 $response = wp_remote_post($url, [
3641 'timeout' => 60,
3642 'headers' => [
3643 'Content-Type' => 'application/json'
3644 ],
3645 'body' => wp_json_encode([
3646 'contents' => [
3647 [
3648 'parts' => [
3649 ['text' => $system_prompt . "\n\n" . $text]
3650 ]
3651 ]
3652 ],
3653 'generationConfig' => [
3654 'temperature' => 0.3
3655 ]
3656 ])
3657 ]);
3658
3659 if (is_wp_error($response)) {
3660 return $response;
3661 }
3662
3663 $body = json_decode(wp_remote_retrieve_body($response), true);
3664
3665 if (isset($body['error'])) {
3666 return new WP_Error('api_error', $body['error']['message']);
3667 }
3668
3669 // plan 781afd: join every text part (skip thought parts), not only parts[0].
3670 $translated = '';
3671 if (isset($body['candidates'][0]['content']['parts']) && is_array($body['candidates'][0]['content']['parts'])) {
3672 foreach ($body['candidates'][0]['content']['parts'] as $part) {
3673 if (is_array($part) && isset($part['text']) && is_string($part['text']) && empty($part['thought'])) {
3674 $translated .= $part['text'];
3675 }
3676 }
3677 }
3678 if (trim($translated) !== '') {
3679 return $translated;
3680 }
3681
3682 $no_text_reason = isset($body['candidates'][0]['finishReason']) && is_string($body['candidates'][0]['finishReason'])
3683 ? $body['candidates'][0]['finishReason']
3684 : (isset($body['promptFeedback']['blockReason']) && is_string($body['promptFeedback']['blockReason']) ? $body['promptFeedback']['blockReason'] : '');
3685 return new WP_Error('api_error', $no_text_reason !== '' ? 'Gemini returned no text (' . $no_text_reason . ')' : 'Invalid API response');
3686 }
3687
3688 /**
3689 * Translate text using OpenRouter API
3690 */
3691 private function translate_with_openrouter($api_key, $model, $system_prompt, $text) {
3692 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [
3693 'timeout' => 60,
3694 'headers' => [
3695 'Authorization' => 'Bearer ' . $api_key,
3696 'Content-Type' => 'application/json',
3697 'HTTP-Referer' => home_url(),
3698 'X-Title' => 'MxChat Translation'
3699 ],
3700 'body' => wp_json_encode([
3701 'model' => $model,
3702 'messages' => [
3703 ['role' => 'system', 'content' => $system_prompt],
3704 ['role' => 'user', 'content' => $text]
3705 ],
3706 'temperature' => 0.3
3707 ])
3708 ]);
3709
3710 if (is_wp_error($response)) {
3711 return $response;
3712 }
3713
3714 $body = json_decode(wp_remote_retrieve_body($response), true);
3715
3716 if (isset($body['error'])) {
3717 return new WP_Error('api_error', $body['error']['message']);
3718 }
3719
3720 if (isset($body['choices'][0]['message']['content'])) {
3721 return $body['choices'][0]['message']['content'];
3722 }
3723
3724 return new WP_Error('api_error', 'Invalid API response');
3725 }
3726
3727 public function mxchat_fetch_chat_history() {
3728 global $wpdb;
3729 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3730 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3731
3732 if (!current_user_can('manage_options')) {
3733 wp_send_json_error(['message' => 'Insufficient permissions']);
3734 wp_die();
3735 }
3736
3737 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3738 $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 50;
3739 $offset = ($page - 1) * $per_page;
3740 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3741 $sort_raw = isset($_POST['sort_order']) ? sanitize_key($_POST['sort_order']) : 'desc';
3742 $allowed_sorts = array('asc', 'desc', 'rating_positive', 'rating_negative');
3743 if (!in_array($sort_raw, $allowed_sorts, true)) { $sort_raw = 'desc'; }
3744 $sort_order = ($sort_raw === 'asc') ? 'ASC' : 'DESC';
3745 $ratings_table = $wpdb->prefix . 'mxchat_session_ratings';
3746 $ratings_join = '';
3747 $rating_table_exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $ratings_table)) === $ratings_table);
3748 if ($rating_table_exists && ($sort_raw === 'rating_positive' || $sort_raw === 'rating_negative')) {
3749 // We sort by rating then recency. Wrap rating_value so NULL sorts last.
3750 $rating_dir = ($sort_raw === 'rating_positive') ? 'DESC' : 'ASC';
3751 $ratings_join = " LEFT JOIN {$ratings_table} r ON r.session_id = t.session_id ";
3752 }
3753
3754 // Build search condition
3755 $search_condition = '';
3756 $search_params = [];
3757 if (!empty($search)) {
3758 $search_condition = "WHERE (
3759 session_id LIKE %s
3760 OR user_email LIKE %s
3761 OR user_name LIKE %s
3762 OR user_identifier LIKE %s
3763 OR message LIKE %s
3764 )";
3765 $search_params = array_fill(0, 5, '%' . $wpdb->esc_like($search) . '%');
3766 }
3767
3768 // Get total count
3769 $count_query = !empty($search)
3770 ? $wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table_name} {$search_condition}", $search_params)
3771 : "SELECT COUNT(DISTINCT session_id) FROM {$table_name}";
3772 $total_sessions = (int) $wpdb->get_var($count_query);
3773
3774 // Get session IDs for current page. Default sort is recency; rating sorts join the ratings table
3775 // and order by rating value (NULLs last) with recency as tiebreaker.
3776 if ($ratings_join) {
3777 $order_by = "ORDER BY (r.rating_value IS NULL), r.rating_value {$rating_dir}, MAX(t.timestamp) DESC";
3778 } else {
3779 $order_by = "ORDER BY MAX(t.timestamp) {$sort_order}";
3780 }
3781 $session_query = !empty($search)
3782 ? $wpdb->prepare(
3783 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} {$search_condition}
3784 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3785 array_merge($search_params, [$per_page, $offset])
3786 )
3787 : $wpdb->prepare(
3788 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join}
3789 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3790 $per_page, $offset
3791 );
3792 $session_ids = $wpdb->get_col($session_query);
3793
3794 $total_pages = ceil($total_sessions / $per_page);
3795
3796 if (empty($session_ids)) {
3797 wp_send_json([
3798 'success' => true,
3799 'sessions' => [],
3800 'page' => $page,
3801 'total_pages' => 0,
3802 'total_sessions' => 0,
3803 'showing_start' => 0,
3804 'showing_end' => 0
3805 ]);
3806 wp_die();
3807 }
3808
3809 // Check for optional columns/tables
3810 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3811 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3812
3813 // Batch-fetch session ratings for this page (plan-a5b006).
3814 $ratings_map = array();
3815 if ($rating_table_exists && !empty($session_ids)) {
3816 $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
3817 $rating_rows = $wpdb->get_results($wpdb->prepare(
3818 "SELECT session_id, rating_value, rating_feedback FROM {$ratings_table} WHERE session_id IN ($placeholders)",
3819 $session_ids
3820 ));
3821 foreach ($rating_rows as $row) {
3822 $ratings_map[$row->session_id] = array(
3823 'value' => (int) $row->rating_value,
3824 'feedback' => (string) $row->rating_feedback,
3825 );
3826 }
3827 }
3828
3829 // Build session list data
3830 $sessions = [];
3831 foreach ($session_ids as $session_id) {
3832 // Get session metadata
3833 $session_data = $originating_columns_exist
3834 ? $wpdb->get_row($wpdb->prepare(
3835 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3836 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3837 $session_id
3838 ))
3839 : $wpdb->get_row($wpdb->prepare(
3840 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3841 WHERE session_id = %s LIMIT 1",
3842 $session_id
3843 ));
3844
3845 // Get message count and latest timestamp
3846 $message_stats = $wpdb->get_row($wpdb->prepare(
3847 "SELECT COUNT(*) as count, MAX(timestamp) as latest FROM {$table_name} WHERE session_id = %s",
3848 $session_id
3849 ));
3850
3851 // Get first user message as preview
3852 $first_user_msg = $wpdb->get_var($wpdb->prepare(
3853 "SELECT message FROM {$table_name} WHERE session_id = %s AND role = 'user' ORDER BY timestamp ASC LIMIT 1",
3854 $session_id
3855 ));
3856 $preview = $first_user_msg ? wp_trim_words(wp_strip_all_tags(stripslashes($first_user_msg)), 12, '...') : 'No messages';
3857
3858 // Build display name
3859 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3860 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3861 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3862
3863 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3864 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3865
3866 // Format time - show relative for recent, date for older
3867 $timestamp = strtotime($message_stats->latest . ' UTC');
3868 $now = time();
3869 $diff = $now - $timestamp;
3870 if ($diff < 3600) {
3871 $time_display = floor($diff / 60) . 'm ago';
3872 } elseif ($diff < 86400) {
3873 $time_display = floor($diff / 3600) . 'h ago';
3874 } elseif ($diff < 604800) {
3875 $time_display = floor($diff / 86400) . 'd ago';
3876 } else {
3877 $time_display = wp_date('M j', $timestamp);
3878 }
3879
3880 // Get initials for avatar
3881 $initials = strtoupper(substr($display_name, 0, 2));
3882 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3883 $parts = explode(' ', $display_name);
3884 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3885 }
3886
3887 $rating_entry = isset($ratings_map[$session_id]) ? $ratings_map[$session_id] : null;
3888 $rating_value = $rating_entry ? $rating_entry['value'] : null;
3889 $rating_feedback = $rating_entry ? $rating_entry['feedback'] : '';
3890
3891 $sessions[] = [
3892 'session_id' => $session_id,
3893 'display_name' => $display_name,
3894 'display_sub' => $display_sub,
3895 'initials' => $initials,
3896 'preview' => $preview,
3897 'message_count' => (int) $message_stats->count,
3898 'time_display' => $time_display,
3899 'timestamp' => $message_stats->latest,
3900 'rating_value' => $rating_value,
3901 'rating_feedback' => $rating_feedback,
3902 ];
3903 }
3904
3905 wp_send_json([
3906 'success' => true,
3907 'sessions' => $sessions,
3908 'page' => $page,
3909 'total_pages' => $total_pages,
3910 'total_sessions' => $total_sessions,
3911 'showing_start' => $offset + 1,
3912 'showing_end' => min($offset + $per_page, $total_sessions)
3913 ]);
3914 wp_die();
3915 }
3916
3917 /**
3918 * Fetch single conversation details for split-panel view
3919 */
3920 public function mxchat_fetch_conversation() {
3921 global $wpdb;
3922 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3923 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3924
3925 if (!current_user_can('manage_options')) {
3926 wp_send_json_error(['message' => 'Insufficient permissions']);
3927 wp_die();
3928 }
3929
3930 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3931 if (empty($session_id)) {
3932 wp_send_json_error(['message' => 'No session ID provided']);
3933 wp_die();
3934 }
3935
3936 // Check for optional columns/tables
3937 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3938 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3939
3940 // Get session metadata
3941 $session_data = $originating_columns_exist
3942 ? $wpdb->get_row($wpdb->prepare(
3943 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3944 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3945 $session_id
3946 ))
3947 : $wpdb->get_row($wpdb->prepare(
3948 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3949 WHERE session_id = %s LIMIT 1",
3950 $session_id
3951 ));
3952
3953 if (!$session_data) {
3954 wp_send_json_error(['message' => 'Session not found']);
3955 wp_die();
3956 }
3957
3958 // Get clicked URLs
3959 $clicked_urls = [];
3960 if ($url_table_exists) {
3961 $url_clicks = $wpdb->get_results($wpdb->prepare(
3962 "SELECT DISTINCT clicked_url FROM {$url_clicks_table}
3963 WHERE session_id = %s ORDER BY click_timestamp ASC",
3964 $session_id
3965 ));
3966 foreach ($url_clicks as $click) {
3967 $clicked_urls[] = $click->clicked_url;
3968 }
3969 }
3970
3971 // Get all messages
3972 $messages = $wpdb->get_results($wpdb->prepare(
3973 "SELECT * FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC",
3974 $session_id
3975 ));
3976
3977 // Build user info
3978 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3979 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3980 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3981
3982 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3983 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3984
3985 // Get initials
3986 $initials = strtoupper(substr($display_name, 0, 2));
3987 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3988 $parts = explode(' ', $display_name);
3989 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3990 }
3991
3992 // Page info
3993 $page_url = $originating_columns_exist && !empty($session_data->originating_page_url) ? $session_data->originating_page_url : '';
3994 $page_title = '';
3995 if ($page_url) {
3996 $page_title = !empty($session_data->originating_page_title) ? $session_data->originating_page_title : parse_url($page_url, PHP_URL_PATH);
3997 }
3998
3999 // Format messages for output
4000 $formatted_messages = [];
4001 foreach ($messages as $msg) {
4002 $is_user = ($msg->role === 'user');
4003 // Live-agent replies (Slack/Telegram handoff) persist with role 'agent'.
4004 // Kept OUT of $is_bot so the RAG Sources link below stays bot-only.
4005 $is_agent = ($msg->role === 'agent');
4006 $is_bot = ($msg->role === 'bot' || $msg->role === 'assistant');
4007
4008 $content = wp_kses(
4009 stripslashes($msg->message),
4010 [
4011 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
4012 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
4013 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []],
4014 'div' => ['class' => [], 'id' => [], 'data-nonce' => []],
4015 'img' => ['src' => [], 'alt' => [], 'class' => []],
4016 'h3' => ['class' => []],
4017 'h4' => ['class' => []],
4018 'button' => ['type' => [], 'class' => [], 'data-product-id' => [], 'data-nonce' => [], 'data-product-type' => [], 'data-original-text' => [], 'data-mxchat-action' => []],
4019 'select' => ['class' => [], 'data-attribute' => []],
4020 'option' => ['value' => []],
4021 'span' => ['class' => []],
4022 'del' => [], 'ins' => [],
4023 ]
4024 );
4025 $formatted_content = $this->format_transcript_message($content);
4026
4027 $has_rag = $is_bot && !empty($msg->rag_context);
4028
4029 $formatted_messages[] = [
4030 'id' => $msg->id,
4031 'role' => $msg->role,
4032 'is_user' => $is_user,
4033 'is_agent' => $is_agent,
4034 'is_bot' => $is_bot,
4035 'content' => $formatted_content,
4036 'timestamp' => wp_date('g:i A', strtotime($msg->timestamp . ' UTC')),
4037 'full_timestamp' => wp_date('F j, Y g:i A', strtotime($msg->timestamp . ' UTC')),
4038 'has_rag' => $has_rag
4039 ];
4040 }
4041
4042 // First message timestamp for "started" display
4043 $started = !empty($messages) ? wp_date('M j, Y g:i A', strtotime($messages[0]->timestamp . ' UTC')) : '-';
4044
4045 // Pull rating_feedback for this session (if any) so the details drawer can show it.
4046 $rating_feedback = '';
4047 $ratings_table_det = $wpdb->prefix . 'mxchat_session_ratings';
4048 if ($wpdb->get_var("SHOW TABLES LIKE '$ratings_table_det'") === $ratings_table_det) {
4049 $rating_feedback = (string) $wpdb->get_var($wpdb->prepare(
4050 "SELECT rating_feedback FROM {$ratings_table_det} WHERE session_id = %s LIMIT 1",
4051 $session_id
4052 ));
4053 }
4054
4055 wp_send_json([
4056 'success' => true,
4057 'session_id' => $session_id,
4058 'user' => [
4059 'name' => $display_name,
4060 'sub' => $display_sub,
4061 'initials' => $initials,
4062 'email' => $user_email,
4063 'identifier' => $user_identifier
4064 ],
4065 'page' => [
4066 'url' => $page_url,
4067 'title' => $page_title
4068 ],
4069 'clicked_urls' => $clicked_urls,
4070 'messages' => $formatted_messages,
4071 'message_count' => count($messages),
4072 'started' => $started,
4073 'rating_feedback' => $rating_feedback
4074 ]);
4075 wp_die();
4076 }
4077
4078
4079 /**
4080 * ALTERNATIVE: Simpler helper method using string replacement
4081 */
4082 private function highlight_clicked_links($message_content, $clicked_urls) {
4083 if (empty($clicked_urls)) {
4084 return wp_kses(
4085 $message_content,
4086 [
4087 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
4088 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
4089 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
4090 ]
4091 );
4092 }
4093
4094 // First apply standard sanitization
4095 $message_content = wp_kses(
4096 $message_content,
4097 [
4098 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
4099 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
4100 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
4101 ]
4102 );
4103
4104 // Process each clicked URL
4105 foreach ($clicked_urls as $clicked_url) {
4106 // Try multiple patterns to catch different link formats
4107 $patterns = [
4108 // Standard link format
4109 '/<a([^>]*href=["\']' . preg_quote($clicked_url, '/') . '["\'][^>]*)>/i',
4110 // Link with trailing slash
4111 '/<a([^>]*href=["\']' . preg_quote(rtrim($clicked_url, '/'), '/') . '\/?["\'][^>]*)>/i',
4112 // Encoded entities version
4113 '/<a([^>]*href=["\']' . preg_quote(htmlentities($clicked_url), '/') . '["\'][^>]*)>/i',
4114 ];
4115
4116 foreach ($patterns as $pattern) {
4117 if (preg_match($pattern, $message_content)) {
4118 $message_content = preg_replace_callback(
4119 $pattern,
4120 function($matches) {
4121 $full_match = $matches[0];
4122 $attributes = $matches[1];
4123
4124 // Check if it already has the class
4125 if (strpos($full_match, 'mxchat-clicked-link') !== false) {
4126 return $full_match;
4127 }
4128
4129 // Check if class attribute exists
4130 if (preg_match('/class=["\']([^"\']*)["\']/', $attributes, $class_matches)) {
4131 // Add to existing class
4132 $new_attributes = preg_replace(
4133 '/class=["\']([^"\']*)["\']/',
4134 'class="$1 mxchat-clicked-link"',
4135 $attributes
4136 );
4137 } else {
4138 // Add new class attribute
4139 $new_attributes = $attributes . ' class="mxchat-clicked-link"';
4140 }
4141
4142 // Add title if not present
4143 if (strpos($new_attributes, 'title=') === false) {
4144 $new_attributes .= ' title="User clicked this link"';
4145 }
4146
4147 return '<a' . $new_attributes . '>';
4148 },
4149 $message_content
4150 );
4151
4152 // If we found and replaced, break out of the patterns loop
4153 break;
4154 }
4155 }
4156 }
4157
4158 return $message_content;
4159 }
4160
4161 public function mxchat_create_prompts_page() {
4162 //error_log('=== DEBUG: mxchat_create_prompts_page started ===');
4163
4164 global $wpdb;
4165 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4166
4167 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4168 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
4169
4170 // Display success message if all prompts were deleted
4171 if (isset($_GET['all_deleted']) && $_GET['all_deleted'] === 'true') {
4172 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>';
4173 }
4174
4175 // Set up pagination, search query, and content type filter
4176 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field($_GET['_wpnonce']) : '';
4177 $search_query = (!empty($nonce) && wp_verify_nonce($nonce, 'mxchat_prompts_search_nonce') && isset($_GET['search'])) ? sanitize_text_field($_GET['search']) : '';
4178 $content_type_filter = isset($_GET['content_type']) ? sanitize_key($_GET['content_type']) : ''; // ADDED 2.5.6
4179 $current_page = isset($_GET['paged']) ? absint($_GET['paged']) : 1;
4180 $per_page = 25;
4181
4182 //error_log('DEBUG: Search query: ' . $search_query);
4183 //error_log('DEBUG: Content type filter: ' . $content_type_filter);
4184 //error_log('DEBUG: Current page: ' . $current_page);
4185 //error_log('DEBUG: Per page: ' . $per_page);
4186
4187 // ================================
4188 // MULTI-BOT CONFIGURATION
4189 // ================================
4190
4191 // Check for multi-bot and set up bot selection
4192 if (class_exists('MxChat_Multi_Bot_Manager')) {
4193 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
4194 $available_bots = $multi_bot_manager->get_available_bots();
4195
4196 // Get saved bot selection (user-specific first, then site-wide default)
4197 $user_id = get_current_user_id();
4198 $saved_bot_id = get_user_meta($user_id, 'mxchat_selected_knowledge_bot', true);
4199 if (empty($saved_bot_id)) {
4200 $saved_bot_id = get_option('mxchat_current_knowledge_bot', 'default');
4201 }
4202
4203 // Allow URL override but default to saved selection
4204 $current_bot_id = isset($_GET['bot_id']) ? sanitize_key($_GET['bot_id']) : $saved_bot_id;
4205 $multibot_active = true;
4206 } else {
4207 $current_bot_id = 'default';
4208 $multibot_active = false;
4209 }
4210
4211 // ================================
4212 // DATA SOURCE CONFIGURATION
4213 // ================================
4214
4215 // Get bot-specific Pinecone settings
4216 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($current_bot_id);
4217 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4218 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4219
4220 // Get OpenAI Vector Store settings
4221 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
4222 $use_vectorstore = ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1';
4223
4224 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
4225 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Has API Key: ' . (!empty($pinecone_api_key) ? 'YES' : 'NO'));
4226 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Host: ' . ($pinecone_options['mxchat_pinecone_host'] ?? 'NOT SET'));
4227 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Namespace: ' . ($pinecone_options['mxchat_pinecone_namespace'] ?? 'NOT SET'));
4228
4229 if ($use_pinecone && !empty($pinecone_api_key)) {
4230 //error_log('DEBUG: Using PINECONE data source with bot-specific config');
4231 // PINECONE DATA SOURCE
4232 $data_source = 'pinecone';
4233
4234 // IMPORTANT: Pass the bot-specific $pinecone_options, not default options!
4235 // UPDATED 2.5.6: Added content_type_filter parameter
4236 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $current_page, $per_page, $current_bot_id, $content_type_filter);
4237
4238 // TEMPORARY DEBUG - Add this right after the fetch call
4239 //error_log('=== DEBUG: Bot switching issue ===');
4240 //error_log('Current bot ID: ' . $current_bot_id);
4241 //error_log('Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
4242 //error_log('Records returned: ' . count($records['data'] ?? []));
4243 //error_log('Total records: ' . ($records['total'] ?? 0));
4244
4245 // Check first few records to see their bot_id
4246 if (!empty($records['data'])) {
4247 foreach (array_slice($records['data'], 0, 3) as $i => $record) {
4248 $record_bot_id = $record->bot_id ?? 'NOT_SET';
4249 //error_log('Record ' . ($i+1) . ' bot_id: ' . $record_bot_id . ', content preview: ' . substr($record->article_content ?? '', 0, 30) . '...');
4250 }
4251 }
4252 //error_log('=== END DEBUG ===');
4253
4254 $total_records = $records['total'] ?? 0;
4255 $prompts = $records['data'] ?? array();
4256 $total_in_database = $records['total_in_database'] ?? 0;
4257 $showing_recent_only = $records['showing_recent_only'] ?? false;
4258 // Plan dd6e10: over 500 vectors the total is the index/namespace vector
4259 // count and pages come from Pinecone's cursor — the template renders
4260 // Previous / Next instead of numbered pages on that path.
4261 $count_unit = $records['count_unit'] ?? '';
4262 $cursor_pagination = !empty($records['cursor_pagination']);
4263 $has_next = !empty($records['has_next']);
4264
4265 $total_pages = ceil($total_records / $per_page);
4266
4267 } else {
4268 //error_log('DEBUG: Using WORDPRESS DB data source');
4269 // WORDPRESS DB DATA SOURCE (your existing logic)
4270 $data_source = 'wordpress';
4271
4272 // Initialize these variables for WordPress DB
4273 $total_in_database = 0;
4274 $showing_recent_only = false;
4275 $count_unit = '';
4276 $cursor_pagination = false;
4277 $has_next = false;
4278
4279 $offset = ($current_page - 1) * $per_page;
4280
4281 // UPDATED 2.5.6: Build WHERE clause for search and content type filtering
4282 $where_clauses = array();
4283 $where_values = array();
4284
4285 if ($search_query) {
4286 $where_clauses[] = "article_content LIKE %s";
4287 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
4288 }
4289
4290 if ($content_type_filter) {
4291 $where_clauses[] = "content_type = %s";
4292 $where_values[] = $content_type_filter;
4293 }
4294
4295 $sql_where = "";
4296 if (!empty($where_clauses)) {
4297 $sql_where = "WHERE " . implode(" AND ", $where_clauses);
4298 }
4299
4300 // UPDATED 2.6.3: Count unique entries (by source_url) instead of individual rows
4301 // This ensures pagination shows X entries per page, not X chunks
4302 // Entries with empty source_url are counted individually
4303 if (!empty($where_values)) {
4304 // Count unique source_urls + count of rows with empty source_url
4305 $count_query = $wpdb->prepare(
4306 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$sql_where} AND source_url != '') +
4307 (SELECT COUNT(*) FROM {$table_name} {$sql_where} AND (source_url = '' OR source_url IS NULL))",
4308 array_merge($where_values, $where_values)
4309 );
4310 } else {
4311 $count_query = "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '') +
4312 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL)";
4313 }
4314 $total_records = $wpdb->get_var($count_query);
4315 $total_pages = ceil($total_records / $per_page);
4316
4317 // UPDATED 2.6.3: Get unique source_urls for pagination, then fetch all their rows
4318 // Step 1: Get the source_urls for this page (distinct URLs ordered by latest timestamp)
4319 if (!empty($where_values)) {
4320 $urls_query = $wpdb->prepare(
4321 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name} {$sql_where}
4322 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
4323 array_merge($where_values, array($per_page, $offset))
4324 );
4325 } else {
4326 $urls_query = $wpdb->prepare(
4327 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
4328 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
4329 $per_page, $offset
4330 );
4331 }
4332 $page_urls = $wpdb->get_results($urls_query);
4333
4334 // Step 2: Fetch all rows for these source_urls
4335 $prompts = array();
4336 if (!empty($page_urls)) {
4337 // Build URL order map to preserve newest-first ordering from step 1
4338 $url_order_map = array();
4339 $url_list = array();
4340 $has_empty_url = false;
4341 $order_index = 0;
4342 foreach ($page_urls as $url_row) {
4343 if (empty($url_row->source_url)) {
4344 $has_empty_url = true;
4345 $url_order_map['__empty__'] = $order_index++;
4346 } else {
4347 $url_list[] = $url_row->source_url;
4348 $url_order_map[$url_row->source_url] = $order_index++;
4349 }
4350 }
4351
4352 // Build query to fetch all rows for these URLs
4353 $url_conditions = array();
4354 $url_values = array();
4355
4356 if (!empty($url_list)) {
4357 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
4358 $url_conditions[] = "source_url IN ($placeholders)";
4359 $url_values = array_merge($url_values, $url_list);
4360 }
4361
4362 if ($has_empty_url) {
4363 $url_conditions[] = "(source_url = '' OR source_url IS NULL)";
4364 }
4365
4366 if (!empty($url_conditions)) {
4367 $url_where = "WHERE (" . implode(" OR ", $url_conditions) . ")";
4368
4369 // Add original filters back
4370 if (!empty($where_clauses)) {
4371 $url_where .= " AND " . implode(" AND ", $where_clauses);
4372 $url_values = array_merge($url_values, $where_values);
4373 }
4374
4375 if (!empty($url_values)) {
4376 $prompts_query = $wpdb->prepare(
4377 "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC",
4378 $url_values
4379 );
4380 } else {
4381 $prompts_query = "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC";
4382 }
4383
4384 $prompts = $wpdb->get_results($prompts_query);
4385
4386 // Sort prompts by the original URL order (newest first), then by timestamp within each URL group
4387 usort($prompts, function($a, $b) use ($url_order_map) {
4388 $url_a = empty($a->source_url) ? '__empty__' : $a->source_url;
4389 $url_b = empty($b->source_url) ? '__empty__' : $b->source_url;
4390 $order_a = $url_order_map[$url_a] ?? PHP_INT_MAX;
4391 $order_b = $url_order_map[$url_b] ?? PHP_INT_MAX;
4392
4393 // First sort by URL order (newest URLs first)
4394 if ($order_a !== $order_b) {
4395 return $order_a - $order_b;
4396 }
4397
4398 // Within same URL, sort by timestamp DESC (newest chunks first)
4399 return strtotime($b->timestamp) - strtotime($a->timestamp);
4400 });
4401 }
4402 }
4403 }
4404
4405 // ================================
4406 // PAGINATION GENERATION
4407 // ================================
4408
4409 // Generate pagination links
4410 if ($total_pages > 1) {
4411 // Build a clean base URL with only necessary parameters
4412 $pagination_args = array('page' => 'mxchat-prompts');
4413
4414 // Preserve bot_id parameter if multi-bot is active
4415 if ($multibot_active && !empty($current_bot_id) && $current_bot_id !== 'default') {
4416 $pagination_args['bot_id'] = $current_bot_id;
4417 }
4418
4419 // Preserve search query and nonce if present
4420 if ($search_query) {
4421 $pagination_args['search'] = $search_query;
4422 if (!empty($nonce)) {
4423 $pagination_args['_wpnonce'] = $nonce;
4424 }
4425 }
4426
4427 // Preserve content type filter if present
4428 if ($content_type_filter) {
4429 $pagination_args['content_type'] = $content_type_filter;
4430 }
4431
4432 // Build clean base URL
4433 $base_url = add_query_arg($pagination_args, admin_url('admin.php'));
4434
4435 $page_links = paginate_links(array(
4436 'base' => $base_url . '%_%',
4437 'format' => '&paged=%#%',
4438 'prev_text' => __('&laquo; Previous', 'mxchat'),
4439 'next_text' => __('Next &raquo;', 'mxchat'),
4440 'total' => $total_pages,
4441 'current' => $current_page,
4442 'type' => 'plain',
4443 ));
4444 } else {
4445 $page_links = '';
4446 }
4447
4448 // ================================
4449 // PROCESSING STATUS RETRIEVAL
4450 // ================================
4451
4452 // Retrieve processing statuses using queue-based method
4453 $processing_statuses = $knowledge_manager->mxchat_get_processing_statuses();
4454 $pdf_status = $processing_statuses['pdf_status'];
4455 $sitemap_status = $processing_statuses['sitemap_status'];
4456 $is_processing = $processing_statuses['is_processing'];
4457
4458 //error_log('=== DEBUG: mxchat_create_prompts_page data preparation completed ===');
4459
4460 // ================================
4461 // RENDER PAGE WITH NEW SIDEBAR LAYOUT
4462 // ================================
4463
4464 // Include the new knowledge page template
4465 require_once plugin_dir_path(__FILE__) . 'admin-knowledge-page.php';
4466
4467 // Package all page data for the render function
4468 $page_data = array(
4469 'prompts' => $prompts,
4470 'total_records' => $total_records,
4471 'total_pages' => $total_pages,
4472 'current_page' => $current_page,
4473 'per_page' => $per_page,
4474 'page_links' => $page_links,
4475 'search_query' => $search_query,
4476 'content_type_filter' => $content_type_filter,
4477 'data_source' => $data_source,
4478 'use_pinecone' => $use_pinecone,
4479 'use_vectorstore' => $use_vectorstore,
4480 'multibot_active' => $multibot_active,
4481 'current_bot_id' => $current_bot_id,
4482 'pdf_status' => $pdf_status,
4483 'sitemap_status' => $sitemap_status,
4484 'is_processing' => $is_processing,
4485 'total_in_database' => $total_in_database ?? 0,
4486 'showing_recent_only' => $showing_recent_only ?? false,
4487 'count_unit' => $count_unit ?? '',
4488 'cursor_pagination' => $cursor_pagination ?? false,
4489 'has_next' => $has_next ?? false,
4490 );
4491
4492 // Render the new sidebar-based page
4493 mxchat_render_knowledge_page($this, $knowledge_manager, $page_data);
4494 }
4495
4496 /**
4497 * Get bot-specific Pinecone configuration
4498 * Used in the knowledge retrieval functions
4499 */
4500 private function get_bot_pinecone_config($bot_id = 'default') {
4501 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4502
4503 // If default bot or multi-bot add-on not active, use default Pinecone config
4504 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4505 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4506 $addon_options = get_option('mxchat_pinecone_addon_options', array());
4507 $config = array(
4508 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4509 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4510 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4511 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4512 );
4513 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4514 return $config;
4515 }
4516
4517 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4518
4519 // Hook for multi-bot add-on to provide bot-specific Pinecone config
4520 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4521
4522 if (!empty($bot_pinecone_config)) {
4523 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4524 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4525 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4526 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4527 } else {
4528 //error_log("MXCHAT DEBUG: Filter returned empty config!");
4529 }
4530
4531 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4532 }
4533
4534 public function mxchat_delete_chat_history() {
4535 if (!current_user_can('manage_options')) {
4536 echo wp_json_encode(['error' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4537 wp_die();
4538 }
4539 check_ajax_referer('mxchat_delete_chat_history', 'security');
4540
4541 if (!isset($_POST['delete_session_ids']) || !is_array($_POST['delete_session_ids'])) {
4542 echo wp_json_encode(['error' => esc_html__('No chat sessions selected for deletion.', 'mxchat')]);
4543 wp_die();
4544 }
4545
4546 global $wpdb;
4547 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4548 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
4549 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
4550
4551 // When true, any lead attached to these sessions is fully wiped (all their sessions,
4552 // across the whole table). Default false: the chat rows go away but the lead is
4553 // preserved as a separate "chat deleted" lead in the Leads tab.
4554 $also_delete_lead = !empty($_POST['also_delete_lead']) && $_POST['also_delete_lead'] !== 'false';
4555
4556 $deleted_count = 0;
4557 $preserved_as_deleted_leads = 0;
4558 $emails_to_fully_wipe = [];
4559
4560 foreach ((array) $_POST['delete_session_ids'] as $session_id) {
4561 $session_id_sanitized = MxChat_Utils::sanitize_session_id($session_id);
4562 if ($session_id_sanitized === '') {
4563 continue;
4564 }
4565
4566 // Capture the lead info attached to this session *before* we delete the rows.
4567 $lead_row = $wpdb->get_row($wpdb->prepare(
4568 "SELECT user_email, user_name, MAX(timestamp) AS last_ts
4569 FROM {$table_name}
4570 WHERE session_id = %s AND user_email IS NOT NULL AND user_email != ''
4571 GROUP BY user_email, user_name
4572 ORDER BY last_ts DESC LIMIT 1",
4573 $session_id_sanitized
4574 ));
4575
4576 wp_cache_delete('chat_session_' . $session_id_sanitized, 'mxchat_chat_sessions');
4577 $wpdb->delete($table_name, ['session_id' => $session_id_sanitized]);
4578
4579 if ($has_translations) {
4580 $wpdb->delete($translations_table, ['session_id' => $session_id_sanitized]);
4581 }
4582
4583 // Read the consent record BEFORE delete_session() destroys the row —
4584 // a preserved "chat deleted" lead must keep its consent proof (b062c4).
4585 $consent_to_preserve = null;
4586 if ($lead_row && !empty($lead_row->user_email) && class_exists('MxChat_Session_Store')) {
4587 $consent_state = MxChat_Session_Store::get($session_id_sanitized, 'consent', '');
4588 if ($consent_state !== '' && $consent_state !== false) {
4589 $consent_to_preserve = [
4590 'given' => (string) $consent_state,
4591 'label' => (string) MxChat_Session_Store::get($session_id_sanitized, 'consent_label', ''),
4592 'at' => (string) MxChat_Session_Store::get($session_id_sanitized, 'consent_at', ''),
4593 ];
4594 }
4595 }
4596
4597 delete_option('mxchat_history_' . $session_id_sanitized);
4598 delete_option('mxchat_agent_name_' . $session_id_sanitized);
4599 if (class_exists('MxChat_Session_Store')) {
4600 MxChat_Session_Store::delete_session($session_id_sanitized); // b64b77
4601 }
4602
4603 if ($lead_row && !empty($lead_row->user_email)) {
4604 if ($also_delete_lead) {
4605 // Full-wipe requested — queue the email so that all their sessions and
4606 // related options get swept below. Also clear this session's pre-chat
4607 // capture options (they're no longer meaningful).
4608 $emails_to_fully_wipe[strtolower($lead_row->user_email)] = $lead_row->user_email;
4609 delete_option('mxchat_email_' . $session_id_sanitized);
4610 delete_option('mxchat_name_' . $session_id_sanitized);
4611 } else {
4612 // Preserve the lead in a "Chat deleted" state via distinct option keys so
4613 // they stay out of the orphan bucket (orphan = pre-chat form dropoff).
4614 update_option('mxchat_lead_del_email_' . $session_id_sanitized, $lead_row->user_email, false);
4615 if (!empty($lead_row->user_name)) {
4616 update_option('mxchat_lead_del_name_' . $session_id_sanitized, $lead_row->user_name, false);
4617 }
4618 if (!empty($lead_row->last_ts)) {
4619 update_option('mxchat_lead_del_ts_' . $session_id_sanitized, $lead_row->last_ts, false);
4620 }
4621 if ($consent_to_preserve !== null) {
4622 update_option('mxchat_lead_del_consent_' . $session_id_sanitized, $consent_to_preserve['given'], false);
4623 update_option('mxchat_lead_del_consent_label_' . $session_id_sanitized, $consent_to_preserve['label'], false);
4624 update_option('mxchat_lead_del_consent_at_' . $session_id_sanitized, $consent_to_preserve['at'], false);
4625 }
4626 // Clean up pre-chat capture options for this session — chat_deleted supersedes.
4627 delete_option('mxchat_email_' . $session_id_sanitized);
4628 delete_option('mxchat_name_' . $session_id_sanitized);
4629 $preserved_as_deleted_leads++;
4630 }
4631 } else {
4632 // No lead attached — nothing to preserve. Clean up any orphan options anyway.
4633 delete_option('mxchat_email_' . $session_id_sanitized);
4634 delete_option('mxchat_name_' . $session_id_sanitized);
4635 }
4636
4637 $deleted_count++;
4638 }
4639
4640 // Opt-in full-lead wipe: sweep every remaining row + every option key (including
4641 // chat_deleted preservation) for each affected email. Reuses the same internal
4642 // helper as the Leads-tab Delete button for consistency.
4643 if (!empty($emails_to_fully_wipe)) {
4644 self::mxchat_wipe_leads_by_email(array_values($emails_to_fully_wipe));
4645 }
4646
4647 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
4648
4649 echo wp_json_encode([
4650 'success' => sprintf(
4651 esc_html__('%d chat session(s) have been deleted.', 'mxchat'),
4652 $deleted_count
4653 ),
4654 'preserved_as_deleted_leads' => $preserved_as_deleted_leads,
4655 'leads_fully_wiped' => count($emails_to_fully_wipe),
4656 ]);
4657 wp_die();
4658 }
4659
4660 /**
4661 * Format transcript message content with markdown processing
4662 * Converts markdown links, bold, italic, code blocks, and plain URLs to HTML
4663 */
4664 private function format_transcript_message($text) {
4665 if (empty($text)) {
4666 return '';
4667 }
4668
4669 // Normalize line endings and clean up excessive whitespace
4670 $text = str_replace("\r\n", "\n", $text);
4671 $text = str_replace("\r", "\n", $text);
4672
4673 // Clean up existing <br> tags that may have been saved (legacy data)
4674 // Convert <br>, <br/>, <br /> back to newlines for consistent processing
4675 $text = preg_replace('/<br\s*\/?>\s*/i', "\n", $text);
4676
4677 // Collapse 3+ consecutive newlines to just 2 (paragraph break)
4678 $text = preg_replace('/\n{3,}/', "\n\n", $text);
4679
4680 // Process markdown headers (# Header)
4681 $text = preg_replace_callback('/^(#{1,6})\s+(.+)$/m', function($matches) {
4682 $level = strlen($matches[1]);
4683 $content = esc_html(trim($matches[2]));
4684 return "<h{$level}>{$content}</h{$level}>";
4685 }, $text);
4686
4687 // Process code blocks with triple backticks
4688 $text = preg_replace_callback('/```(\w+)?\n?([\s\S]*?)```/', function($matches) {
4689 $language = !empty($matches[1]) ? ' class="language-' . esc_attr($matches[1]) . '"' : '';
4690 $code = esc_html($matches[2]);
4691 return "<pre><code{$language}>{$code}</code></pre>";
4692 }, $text);
4693
4694 // Process inline code with single backticks
4695 $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
4696
4697 // Process bold text **text** or __text__
4698 $text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
4699 $text = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $text);
4700
4701 // Process italic text *text* or _text_ (but not if part of URL)
4702 $text = preg_replace('/(?<![*_\w])\*([^*]+)\*(?![*\w])/', '<em>$1</em>', $text);
4703 $text = preg_replace('/(?<![*_\w])_([^_]+)_(?![*\w])/', '<em>$1</em>', $text);
4704
4705 // Process markdown links [text](url)
4706 $text = preg_replace_callback('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', function($matches) {
4707 $link_text = esc_html($matches[1]);
4708 $url = esc_url($matches[2]);
4709 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$link_text}</a>";
4710 }, $text);
4711
4712 // Process citation-style brackets [URL]
4713 $text = preg_replace_callback('/\[(https?:\/\/[^\]]+)\]/', function($matches) {
4714 $url = esc_url($matches[1]);
4715 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$url}</a>";
4716 }, $text);
4717
4718 // Process standalone URLs (not already in links or img src)
4719 $text = preg_replace_callback(
4720 '/(?<!href="|src="|">)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/',
4721 function($matches) {
4722 $url = esc_url($matches[1]);
4723 // Truncate display URL if too long
4724 $display = strlen($matches[1]) > 50 ? substr($matches[1], 0, 47) . '...' : $matches[1];
4725 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$display}</a>";
4726 },
4727 $text
4728 );
4729
4730 // Process mailto links
4731 $text = preg_replace_callback('/\[([^\]]+)\]\((mailto:[^)]+)\)/', function($matches) {
4732 $link_text = esc_html($matches[1]);
4733 $mailto = esc_url($matches[2]);
4734 return "<a href=\"{$mailto}\">{$link_text}</a>";
4735 }, $text);
4736
4737 // Convert paragraphs: split by double newlines, wrap in <p> tags
4738 // This creates proper paragraph structure instead of excessive <br> tags
4739 $paragraphs = preg_split('/\n\n+/', $text);
4740
4741 // Filter out empty paragraphs but preserve content like "0"
4742 $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), function($p) {
4743 return $p !== '';
4744 }));
4745
4746 if (empty($paragraphs)) {
4747 // No content after filtering
4748 return '';
4749 } elseif (count($paragraphs) > 1) {
4750 // Multiple paragraphs - wrap each in <p> tags, convert single newlines to <br>
4751 $formatted_paragraphs = array_map(function($p) {
4752 return nl2br($p);
4753 }, $paragraphs);
4754 $text = '<p>' . implode('</p><p>', $formatted_paragraphs) . '</p>';
4755 } else {
4756 // Single paragraph - just convert newlines to <br>
4757 $text = nl2br($paragraphs[0]);
4758 }
4759
4760 return $text;
4761 }
4762
4763 /**
4764 * AJAX handler to fetch RAG context for a specific message
4765 * Used by the transcript viewer to show retrieved documents
4766 */
4767 public function mxchat_get_rag_context() {
4768 // Check permissions
4769 if (!current_user_can('manage_options')) {
4770 wp_send_json_error(['message' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4771 wp_die();
4772 }
4773
4774 // Validate message ID
4775 if (!isset($_POST['message_id']) || empty($_POST['message_id'])) {
4776 wp_send_json_error(['message' => esc_html__('Message ID is required.', 'mxchat')]);
4777 wp_die();
4778 }
4779
4780 $message_id = absint($_POST['message_id']);
4781
4782 global $wpdb;
4783 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4784
4785 // Fetch the RAG context for this message
4786 $result = $wpdb->get_row($wpdb->prepare(
4787 "SELECT rag_context FROM {$table_name} WHERE id = %d",
4788 $message_id
4789 ));
4790
4791 if (!$result || empty($result->rag_context)) {
4792 wp_send_json_error(['message' => esc_html__('No RAG context found for this message.', 'mxchat')]);
4793 wp_die();
4794 }
4795
4796 // Decode the JSON data
4797 $rag_context = json_decode($result->rag_context, true);
4798
4799 if (json_last_error() !== JSON_ERROR_NONE) {
4800 wp_send_json_error(['message' => esc_html__('Invalid RAG context data.', 'mxchat')]);
4801 wp_die();
4802 }
4803
4804 wp_send_json_success($rag_context);
4805 wp_die();
4806 }
4807
4808 public function display_admin_notices() {
4809 // Check if we're on a MXChat admin page
4810 $screen = get_current_screen();
4811 if (!$screen || strpos($screen->base, 'mxchat') === false) {
4812 return;
4813 }
4814
4815 $dismiss_button = '<button type="button" class="notice-dismiss"><span class="screen-reader-text">' . esc_html__('Dismiss this notice.', 'mxchat') . '</span></button>';
4816
4817 // Check for error notices
4818 $error_notice = get_transient('mxchat_admin_notice_error');
4819 if ($error_notice) {
4820 echo '<div class="notice notice-error is-dismissible"><p>' . wp_kses_post($error_notice) . '</p>' . $dismiss_button . '</div>';
4821 delete_transient('mxchat_admin_notice_error');
4822 }
4823
4824 // Check for success notices
4825 $success_notice = get_transient('mxchat_admin_notice_success');
4826 if ($success_notice) {
4827 echo '<div class="notice notice-success is-dismissible"><p>' . wp_kses_post($success_notice) . '</p>' . $dismiss_button . '</div>';
4828 delete_transient('mxchat_admin_notice_success');
4829 }
4830
4831 // Check for info notices
4832 $info_notice = get_transient('mxchat_admin_notice_info');
4833 if ($info_notice) {
4834 echo '<div class="notice notice-info is-dismissible"><p>' . wp_kses_post($info_notice) . '</p>' . $dismiss_button . '</div>';
4835 delete_transient('mxchat_admin_notice_info');
4836 }
4837 }
4838
4839
4840 public function mxchat_create_activation_page() {
4841 // Include the new Pro & Extensions page template
4842 require_once plugin_dir_path(__FILE__) . 'admin-pro-page.php';
4843
4844 // Get addons configuration from the MxChat_Addons class
4845 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
4846 $addons_instance = new MxChat_Addons();
4847 $addons_config = $addons_instance->get_addons_config();
4848
4849 // Render the consolidated Pro & Extensions page
4850 mxchat_render_pro_page($this, $addons_config);
4851 }
4852
4853 /**
4854 * Check if current activation is linked to a domain
4855 * This checks YOUR website's database, not the user's local database
4856 */
4857 public function is_current_activation_linked($domain) {
4858 $license_key = get_option('mxchat_activation_key');
4859 $email = get_option('mxchat_pro_email');
4860
4861 if (empty($license_key) || empty($email)) {
4862 return false;
4863 }
4864
4865 // Check with YOUR website's API
4866 $response = wp_remote_post('https://mxchat.ai/mxchat-api/check-domain', array(
4867 'body' => array(
4868 'license_key' => $license_key,
4869 'email' => $email,
4870 'domain' => $domain
4871 ),
4872 'timeout' => 10,
4873 'sslverify' => false
4874 ));
4875
4876 if (is_wp_error($response)) {
4877 return false;
4878 }
4879
4880 $body = json_decode(wp_remote_retrieve_body($response), true);
4881 return isset($body['success']) && $body['success'] && isset($body['data']['linked']) && $body['data']['linked'];
4882 }
4883
4884
4885 public function mxchat_actions_page_html() {
4886 if (!current_user_can('manage_options')) {
4887 return;
4888 }
4889
4890 global $wpdb;
4891 $table_name = $wpdb->prefix . 'mxchat_intents';
4892
4893 // Get stats for dashboard
4894 $total_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
4895 $enabled_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE enabled = 1");
4896 $disabled_actions = $total_actions - $enabled_actions;
4897
4898 // Get unique action types count
4899 $action_types_count = $wpdb->get_var("SELECT COUNT(DISTINCT callback_function) FROM $table_name");
4900
4901 // Get action type distribution
4902 $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");
4903 $available_callbacks = $this->mxchat_get_available_callbacks();
4904 $callback_groups = $this->mxchat_get_available_callbacks(true, true);
4905
4906 $action_type_distribution = array();
4907 foreach ($type_distribution_raw as $row) {
4908 $label = isset($available_callbacks[$row->callback_function]['label'])
4909 ? $available_callbacks[$row->callback_function]['label']
4910 : $row->callback_function;
4911 $action_type_distribution[$label] = $row->count;
4912 }
4913
4914 // Native function-calling (AI Tools) data — plan-mxchat-20260617-a41dee.
4915 // The AI Tools checklist reads from MxChat_Tool_Registry, the SAME single
4916 // source the chat-time function-calling loop reads, so the two never drift.
4917 if (!class_exists('MxChat_Tool_Registry')) {
4918 require_once plugin_dir_path(__FILE__) . 'class-mxchat-tool-registry.php';
4919 }
4920 if (!class_exists('MxChat_Model_Catalog')) {
4921 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
4922 }
4923 $fc_options = get_option('mxchat_options', array());
4924 $fc_current_model = isset($fc_options['model']) ? $fc_options['model'] : 'gpt-5.6-sol';
4925 $fc_model_capable = class_exists('MxChat_Model_Catalog')
4926 ? MxChat_Model_Catalog::supports_tools($fc_current_model) : true;
4927 $active_tab = (isset($_GET['tab']) && $_GET['tab'] === 'ai-tools') ? 'ai-tools' : 'dashboard';
4928
4929 // Enrich each AI Tool with the dashicon its callback already uses on the
4930 // Trigger Phrases "Choose what it does" grid, so the AI Tools cards/modal
4931 // share the same iconography (plan 8bbf98 part 4). View-layer only — the
4932 // registry's model-facing data is untouched. Default admin-generic.
4933 $fc_tools = MxChat_Tool_Registry::available_tools();
4934 foreach ($fc_tools as &$fc_tool_ref) {
4935 $fc_cb_ref = $fc_tool_ref['callback'];
4936 $fc_tool_ref['icon'] = isset($available_callbacks[$fc_cb_ref]['icon'])
4937 ? $available_callbacks[$fc_cb_ref]['icon']
4938 : 'admin-generic';
4939 }
4940 unset($fc_tool_ref);
4941
4942 // Count of ACTIVE tools — drives the AI Tools sidebar nav badge, mirroring
4943 // $total_actions for Trigger Phrases. A tool in the list = active (plan
4944 // d450a7), so the badge shows the same number the list pane shows (plan 5f7409).
4945 $total_tools = count(array_filter($fc_tools, function ($t) {
4946 return !empty($t['enabled']);
4947 }));
4948
4949 // Brave-key dependency check (plan 183856): Web Search + Image Search run on
4950 // the Brave Search API. If either is enabled as a tool but no brave_api_key is
4951 // configured, surface a graceful "key not set" notice so the admin isn't met
4952 // with a silently no-firing tool.
4953 $fc_brave_missing = false;
4954 $brave_key = isset($fc_options['brave_api_key']) ? trim($fc_options['brave_api_key']) : '';
4955 if ($brave_key === '') {
4956 foreach ($fc_tools as $fc_t) {
4957 if (!empty($fc_t['enabled']) && isset($fc_t['requires_key']) && $fc_t['requires_key'] === 'brave_api_key') {
4958 $fc_brave_missing = true;
4959 break;
4960 }
4961 }
4962 }
4963
4964 // Prepare page data
4965 $page_data = array(
4966 'total_actions' => $total_actions,
4967 'enabled_actions' => $enabled_actions,
4968 'disabled_actions' => $disabled_actions,
4969 'action_types_count' => $action_types_count,
4970 'action_type_distribution' => $action_type_distribution,
4971 'available_callbacks' => $available_callbacks,
4972 'callback_groups' => $callback_groups,
4973 // AI Tools section
4974 'active_tab' => $active_tab,
4975 'fc_enabled' => MxChat_Tool_Registry::is_enabled(),
4976 'total_tools' => $total_tools,
4977 'fc_tools' => $fc_tools,
4978 'fc_current_model' => $fc_current_model,
4979 'fc_model_capable' => $fc_model_capable,
4980 'fc_brave_missing' => $fc_brave_missing,
4981 'fc_saved' => isset($_GET['mxchat_fc_saved']),
4982 );
4983
4984 // Include and render the new template
4985 require_once plugin_dir_path(__FILE__) . 'admin-actions-page.php';
4986 mxchat_render_actions_page($this, $page_data);
4987 }
4988
4989 /**
4990 * LEGACY HTML - Preserved below for reference, to be removed in future update
4991 */
4992 function mxchat_actions_page_legacy_html() {
4993 // This function is deprecated and no longer used
4994 // The new template is in includes/admin-actions-page.php
4995 ?>
4996 <div class="wrap mxchat-wrapper">
4997 <!-- Hero Section -->
4998 <div class="mxchat-hero">
4999 <h1 class="mxchat-main-title">
5000 <span class="mxchat-gradient-text">Actions</span> Manager
5001 </h1>
5002 <p class="mxchat-hero-subtitle">
5003 <?php esc_html_e('Create and manage custom actions to enhance your chatbot\'s capabilities.', 'mxchat'); ?>
5004 </p>
5005 </div>
5006
5007 <!-- Actions Header with Search and Filter -->
5008 <div class="mxchat-actions-header">
5009 <div class="mxchat-actions-filters">
5010 <form method="get" class="mxchat-search-form">
5011 <input type="hidden" name="page" value="mxchat-actions">
5012 <div class="mxchat-search-group">
5013 <span class="dashicons dashicons-search"></span>
5014 <input type="text" name="s" class="mxchat-search-input"
5015 placeholder="<?php esc_attr_e('Search Actions', 'mxchat'); ?>"
5016 value="<?php echo esc_attr($search_term); ?>">
5017 </div>
5018 <select name="callback_filter" class="mxchat-action-filter">
5019 <option value=""><?php esc_html_e('All Action Types', 'mxchat'); ?></option>
5020 <?php foreach ($available_callbacks as $function => $callback_data) :
5021 $label = $callback_data['label']; ?>
5022 <option value="<?php echo esc_attr($function); ?>"
5023 <?php selected($callback_filter, $function); ?>>
5024 <?php echo esc_html($label); ?>
5025 </option>
5026 <?php endforeach; ?>
5027 </select>
5028 <button type="submit" class="mxchat-button-secondary">
5029 <?php esc_html_e('Filter', 'mxchat'); ?>
5030 </button>
5031 </form>
5032 </div>
5033 <div class="mxchat-actions-controls">
5034 <button type="button" id="mxchat-add-action-btn" class="mxchat-button-primary">
5035 <span class="dashicons dashicons-plus-alt"></span>
5036 <?php esc_html_e('Add New Action', 'mxchat'); ?>
5037 </button>
5038 </div>
5039 </div>
5040
5041 <!-- Actions Grid Layout - All actions in a single grid -->
5042 <div class="mxchat-actions-grid">
5043 <div class="mxchat-cards-container">
5044 <?php if (!empty($actions)) : ?>
5045 <?php foreach ($actions as $action) :
5046 $callback_function = $action->callback_function;
5047 $callback_label = isset($available_callbacks[$callback_function]['label'])
5048 ? $available_callbacks[$callback_function]['label']
5049 : $callback_function;
5050 $threshold_value = isset($action->similarity_threshold)
5051 ? round($action->similarity_threshold * 100)
5052 : 85;
5053
5054 // Check if this is a form action
5055 $is_form_action = strpos($action->intent_label, 'Form ') === 0;
5056
5057 // Get action status (enabled/disabled) - default to true if column doesn't exist
5058 $is_enabled = isset($action->enabled) ? (bool)$action->enabled : true;
5059
5060 // Get enabled bots for display
5061 $enabled_bots = [];
5062 if (isset($action->enabled_bots) && !empty($action->enabled_bots)) {
5063 $enabled_bots = json_decode($action->enabled_bots, true);
5064 if (!is_array($enabled_bots)) {
5065 $enabled_bots = ['default'];
5066 }
5067 } else {
5068 $enabled_bots = ['default']; // Backward compatibility
5069 }
5070 ?>
5071 <div class="mxchat-action-card <?php echo $is_form_action ? 'mxchat-form-action' : ''; ?>">
5072 <div class="mxchat-card-header">
5073 <div class="mxchat-card-title"><?php echo esc_html($action->intent_label); ?></div>
5074 <div class="mxchat-card-toggle">
5075 <label class="mxchat-switch">
5076 <input type="checkbox" class="mxchat-action-toggle"
5077 data-action-id="<?php echo esc_attr($action->id); ?>"
5078 <?php checked($is_enabled); ?>>
5079 <span class="mxchat-slider round"></span>
5080 </label>
5081 </div>
5082 </div>
5083
5084 <div class="mxchat-card-body">
5085 <div class="mxchat-card-description">
5086 <strong><?php esc_html_e('Type:', 'mxchat'); ?></strong>
5087 <?php echo esc_html($callback_label); ?>
5088 </div>
5089
5090 <div class="mxchat-card-phrases">
5091 <strong><?php esc_html_e('Trigger phrases:', 'mxchat'); ?></strong>
5092 <div class="mxchat-phrases-preview">
5093 <?php
5094 // Check if the helper function exists, otherwise use a simple substring
5095 if (method_exists($this, 'get_trimmed_phrases')) {
5096 echo esc_html($this->get_trimmed_phrases($action->phrases));
5097 } else {
5098 echo esc_html(strlen($action->phrases) > 100 ?
5099 substr($action->phrases, 0, 97) . '...' :
5100 $action->phrases);
5101 }
5102 ?>
5103 </div>
5104 </div>
5105
5106 <div class="mxchat-threshold-control">
5107 <div class="mxchat-threshold-label">
5108 <?php esc_html_e('Similarity Threshold:', 'mxchat'); ?>
5109 <span class="mxchat-threshold-value"><?php echo esc_html($threshold_value); ?>%</span>
5110 </div>
5111 </div>
5112
5113 <!-- Bot Availability Display (Read-only) -->
5114 <div class="mxchat-card-bots">
5115 <strong><?php esc_html_e('Assigned bots:', 'mxchat'); ?></strong>
5116 <div class="mxchat-bot-badges">
5117 <?php
5118 foreach ($enabled_bots as $bot_id) {
5119 if ($bot_id === 'default') {
5120 echo '<span class="mxchat-bot-badge">' . esc_html__('Default Bot', 'mxchat') . '</span>';
5121 } else {
5122 // Try to get bot name from multi-bot manager
5123 if (class_exists('MxChat_Multi_Bot_Core_Manager')) {
5124 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
5125 $available_bots = $multi_bot_manager->get_available_bots();
5126 $bot_name = isset($available_bots[$bot_id]) ? $available_bots[$bot_id] : $bot_id;
5127 echo '<span class="mxchat-bot-badge">' . esc_html($bot_name) . '</span>';
5128 } else {
5129 echo '<span class="mxchat-bot-badge">' . esc_html($bot_id) . '</span>';
5130 }
5131 }
5132 }
5133 ?>
5134 </div>
5135 </div>
5136 </div>
5137
5138 <div class="mxchat-card-footer">
5139 <?php
5140 // Check if it's a form action
5141 $is_form_action = preg_match('/Form (\d+)/', $action->intent_label, $form_matches);
5142
5143 // Check if it's a recommendation flow action
5144 $is_flow_action = preg_match('/Recommendation Flow (\d+)/', $action->intent_label, $flow_matches);
5145
5146 if ($is_form_action) {
5147 $form_id = isset($form_matches[1]) ? $form_matches[1] : '';
5148 ?>
5149 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-forms&action=edit&form_id=' . $form_id)); ?>"
5150 class="mxchat-button-primary">
5151 <span class="dashicons dashicons-feedback"></span>
5152 <?php esc_html_e('Edit Form', 'mxchat'); ?>
5153 </a>
5154 <?php } elseif ($is_flow_action) {
5155 ?>
5156 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-smart-recommender')); ?>"
5157 class="mxchat-button-primary">
5158 <span class="dashicons dashicons-list-view"></span>
5159 <?php esc_html_e('Manage Flows', 'mxchat'); ?>
5160 </a>
5161 <?php } else { ?>
5162 <button type="button"
5163 class="mxchat-button-secondary mxchat-edit-button"
5164 data-action-id="<?php echo esc_attr($action->id); ?>"
5165 data-phrases="<?php echo esc_attr($action->phrases); ?>"
5166 data-label="<?php echo esc_attr($action->intent_label); ?>"
5167 data-threshold="<?php echo esc_attr(round($action->similarity_threshold * 100)); ?>"
5168 data-callback-function="<?php echo esc_attr($action->callback_function); ?>"
5169 data-enabled-bots="<?php echo esc_attr(json_encode($enabled_bots)); ?>">
5170 <span class="dashicons dashicons-edit"></span>
5171 <?php esc_html_e('Edit', 'mxchat'); ?>
5172 </button>
5173 <form method="post"
5174 action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
5175 class="mxchat-delete-form"
5176 onsubmit="return confirm('<?php esc_attr_e('Are you sure you want to delete this action?', 'mxchat'); ?>');">
5177 <?php wp_nonce_field('mxchat_delete_intent_nonce'); ?>
5178 <input type="hidden" name="action" value="mxchat_delete_intent">
5179 <input type="hidden" name="intent_id" value="<?php echo esc_attr($action->id); ?>">
5180 <button type="submit" class="mxchat-button-text mxchat-delete-button">
5181 <span class="dashicons dashicons-trash"></span>
5182 <?php esc_html_e('Delete', 'mxchat'); ?>
5183 </button>
5184 </form>
5185 <?php } ?>
5186 </div>
5187 </div>
5188 <?php endforeach; ?>
5189 <?php else : ?>
5190 <!-- If no actions found -->
5191 <div class="mxchat-no-actions">
5192 <div class="mxchat-empty-state">
5193 <span class="dashicons dashicons-format-chat"></span>
5194 <h2><?php esc_html_e('No actions found', 'mxchat'); ?></h2>
5195 <p><?php esc_html_e('Get started by creating your first action to enhance your chatbot.', 'mxchat'); ?></p>
5196 <button type="button" id="mxchat-create-first-action" class="mxchat-button-primary">
5197 <?php esc_html_e('Create Your First Action', 'mxchat'); ?>
5198 </button>
5199 </div>
5200 </div>
5201 <?php endif; ?>
5202 </div>
5203 </div>
5204
5205 <?php if ($total_pages > 1) : ?>
5206 <div class="mxchat-pagination">
5207 <?php
5208 echo paginate_links(array(
5209 'base' => add_query_arg('paged', '%#%'),
5210 'format' => '',
5211 'prev_text' => __('&laquo; Previous', 'mxchat'),
5212 'next_text' => __('Next &raquo;', 'mxchat'),
5213 'total' => $total_pages,
5214 'current' => $page
5215 ));
5216 ?>
5217 </div>
5218 <?php endif; ?>
5219
5220 <!-- Add/Edit Action Modal with Step-Based Approach -->
5221 <!-- Complete Modal HTML with Defined Groups Variable -->
5222 <div id="mxchat-action-modal" class="mxchat-modal" style="display: none;">
5223 <div class="mxchat-modal-content">
5224 <span class="mxchat-modal-close">&times;</span>
5225
5226 <form id="mxchat-action-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
5227 <!-- Dynamic nonce field -->
5228 <div id="action-nonce-container">
5229 <?php wp_nonce_field('mxchat_add_intent_nonce', 'add_intent_nonce'); ?>
5230 </div>
5231 <input type="hidden" name="action" id="form_action_type" value="mxchat_add_intent">
5232 <input type="hidden" name="intent_id" id="edit_action_id" value="">
5233 <input type="hidden" name="callback_function" id="callback_function" value="">
5234
5235 <!-- Step 1: Action Type Selection -->
5236 <div id="mxchat-action-step-1" class="mxchat-action-step active">
5237 <div class="mxchat-step-indicator">
5238 <div class="mxchat-step-number">1</div>
5239 <div class="mxchat-step-title"><?php esc_html_e('Select Action Type', 'mxchat'); ?></div>
5240 </div>
5241
5242 <div id="mxchat-action-type-selector" class="mxchat-action-type-selector">
5243 <div class="mxchat-action-type-search">
5244 <span class="dashicons dashicons-search"></span>
5245 <input type="text" id="action-type-search" placeholder="<?php esc_attr_e('Search action types...', 'mxchat'); ?>" class="mxchat-action-type-search-input">
5246 </div>
5247
5248 <?php
5249 // Get the callbacks - IMPORTANT: Define the $groups variable here
5250 $groups = $this->mxchat_get_available_callbacks(true, true);
5251 ?>
5252
5253 <div class="mxchat-action-type-categories">
5254 <button type="button" class="mxchat-category-button active" data-category="all"><?php esc_html_e('All', 'mxchat'); ?></button>
5255 <?php
5256 // Get unique categories from the defined groups
5257 foreach ($groups as $group_label => $group_callbacks) :
5258 $category_slug = sanitize_title($group_label);
5259 ?>
5260 <button type="button" class="mxchat-category-button" data-category="<?php echo esc_attr($category_slug); ?>"><?php echo esc_html($group_label); ?></button>
5261 <?php endforeach; ?>
5262 </div>
5263
5264 <div class="mxchat-action-types-grid">
5265 <?php
5266 // Generate action cards from available callbacks
5267 foreach ($groups as $group_label => $group_callbacks) :
5268 $category_slug = sanitize_title($group_label);
5269
5270 foreach ($group_callbacks as $function => $data) :
5271 $label = $data['label'];
5272 $icon = isset($data['icon']) ? $data['icon'] : 'admin-generic';
5273 $description = isset($data['description']) ? $data['description'] : '';
5274 $is_addon = isset($data['addon']) && $data['addon'] !== false;
5275 $addon_name = isset($data['addon_name']) ? $data['addon_name'] : '';
5276 $is_installed = isset($data['installed']) ? $data['installed'] : true;
5277 $is_promo = !empty($data['addon_promo']) && !$is_installed;
5278
5279 // Determine card status and styling
5280 $card_class = 'mxchat-action-type-card';
5281 $icon_class = 'mxchat-action-type-icon';
5282 $status_badge = '';
5283
5284 if ($is_promo) {
5285 // Promotional card — not selectable, just informational
5286 $card_class .= ' not-installed mxchat-promo-card';
5287 $status_badge = '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
5288 } elseif ($is_addon && !$is_installed) {
5289 // Add-on not installed
5290 $card_class .= ' not-installed';
5291 $status_badge .= '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
5292 }
5293
5294 // Default description if none provided
5295 if (empty($description)) {
5296 $description = sprintf(
5297 esc_html__('Use the %s action in your chatbot', 'mxchat'),
5298 $label
5299 );
5300 }
5301 ?>
5302 <div class="<?php echo esc_attr($card_class); ?>"
5303 data-category="<?php echo esc_attr($category_slug); ?>"
5304 <?php if (!$is_promo) : ?>
5305 data-value="<?php echo esc_attr($function); ?>"
5306 data-label="<?php echo esc_attr($label); ?>"
5307 <?php endif; ?>
5308 data-pro="false"
5309 data-addon="<?php echo esc_attr($is_addon ? $data['addon'] : ''); ?>"
5310 data-installed="<?php echo $is_installed ? 'true' : 'false'; ?>"
5311 <?php if ($is_promo) : ?>data-promo="true"<?php endif; ?>>
5312 <div class="<?php echo esc_attr($icon_class); ?>">
5313 <span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span>
5314 </div>
5315 <div class="mxchat-action-type-info">
5316 <h4><?php echo esc_html($label); ?></h4>
5317 <p><?php echo esc_html($description); ?></p>
5318 <?php if (!empty($status_badge)) : ?>
5319 <?php echo $status_badge; ?>
5320 <?php endif; ?>
5321
5322 <?php if ($is_promo || ($is_addon && !$is_installed)) : ?>
5323 <div class="mxchat-addon-info">
5324 <?php echo esc_html(sprintf(
5325 __('Requires %s', 'mxchat'),
5326 $addon_name
5327 )); ?>
5328 — <a href="https://mxchat.ai/" target="_blank"><?php esc_html_e('Get Add-on', 'mxchat'); ?></a>
5329 </div>
5330 <?php endif; ?>
5331 </div>
5332 </div>
5333 <?php endforeach;
5334 endforeach; ?>
5335 </div>
5336 </div>
5337
5338 <div class="mxchat-modal-actions">
5339 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
5340 <?php esc_html_e('Cancel', 'mxchat'); ?>
5341 </button>
5342 </div>
5343 </div>
5344
5345 <!-- Step 2: Action Configuration -->
5346 <div id="mxchat-action-step-2" class="mxchat-action-step">
5347 <div class="mxchat-step-indicator">
5348 <div class="mxchat-step-number">2</div>
5349 <div class="mxchat-step-title"><?php esc_html_e('Configure Action', 'mxchat'); ?></div>
5350 </div>
5351
5352 <div class="mxchat-selected-action">
5353 <button type="button" class="mxchat-back-button" id="mxchat-back-to-step-1">
5354 <span class="dashicons dashicons-arrow-left-alt"></span>
5355 <?php esc_html_e('Back to Action Types', 'mxchat'); ?>
5356 </button>
5357 <div class="mxchat-selected-action-info">
5358 <div id="selected-action-icon" class="mxchat-action-type-icon">
5359 <span class="dashicons dashicons-admin-generic"></span>
5360 </div>
5361 <div class="mxchat-selected-action-details">
5362 <h3 id="selected-action-title"><?php esc_html_e('Selected Action', 'mxchat'); ?></h3>
5363 <p id="selected-action-description"><?php esc_html_e('Configure this action for your chatbot', 'mxchat'); ?></p>
5364 </div>
5365 </div>
5366 </div>
5367
5368 <div class="mxchat-form-group">
5369 <label for="intent_label">
5370 <?php esc_html_e('Action Label (For your reference only)', 'mxchat'); ?>
5371 </label>
5372 <input name="intent_label" type="text" id="intent_label" required
5373 class="mxchat-intent-input"
5374 placeholder="<?php esc_attr_e('Example: Newsletter Signup', 'mxchat'); ?>">
5375 </div>
5376
5377 <div class="mxchat-form-group">
5378 <label for="phrases">
5379 <?php esc_html_e('Trigger Phrases (comma-separated)', 'mxchat'); ?>
5380 </label>
5381 <textarea name="phrases" id="action_phrases" rows="5" required
5382 class="mxchat-intent-textarea"
5383 placeholder="<?php esc_attr_e('Example: sign me up, subscribe me, I want to join, add me to the newsletter', 'mxchat'); ?>"></textarea>
5384 </div>
5385
5386 <div class="mxchat-form-group">
5387 <label for="similarity_threshold">
5388 <?php esc_html_e('Similarity Threshold', 'mxchat'); ?>
5389 <span class="mxchat-threshold-value-display">85%</span>
5390 </label>
5391 <div class="mxchat-slider-group modal-slider">
5392 <input type="range"
5393 name="similarity_threshold"
5394 id="similarity_threshold"
5395 min="10"
5396 max="95"
5397 value="85"
5398 class="mxchat-intent-slider"
5399 oninput="document.querySelector('.mxchat-threshold-value-display').textContent = this.value + '%'">
5400 </div>
5401 <div class="mxchat-threshold-hint">
5402 <?php esc_html_e('Lower values (10-30) make the action trigger more easily. Higher values (70-95) require more exact matches.', 'mxchat'); ?>
5403 </div>
5404 </div>
5405
5406 <!-- Bot Selection Section -->
5407 <div class="mxchat-form-group">
5408 <label for="enabled_bots">
5409 <?php esc_html_e('Which bots should we enable this action for?', 'mxchat'); ?>
5410 </label>
5411 <div class="mxchat-bot-selector">
5412 <div class="mxchat-bot-option">
5413 <label class="mxchat-checkbox-label">
5414 <input type="checkbox"
5415 name="enabled_bots[]"
5416 value="default"
5417 id="bot_default"
5418 checked="checked">
5419 <span class="mxchat-checkmark"></span>
5420 <?php esc_html_e('Default Bot', 'mxchat'); ?>
5421 </label>
5422 </div>
5423
5424 <?php if (class_exists('MxChat_Multi_Bot_Core_Manager')) :
5425 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
5426 $available_bots = $multi_bot_manager->get_available_bots();
5427
5428 foreach ($available_bots as $bot_id => $bot_name) :
5429 if ($bot_id === 'default') continue; // Skip default, already shown above
5430 ?>
5431 <div class="mxchat-bot-option">
5432 <label class="mxchat-checkbox-label">
5433 <input type="checkbox"
5434 name="enabled_bots[]"
5435 value="<?php echo esc_attr($bot_id); ?>"
5436 id="bot_<?php echo esc_attr($bot_id); ?>">
5437 <span class="mxchat-checkmark"></span>
5438 <?php echo esc_html($bot_name); ?>
5439 </label>
5440 </div>
5441 <?php
5442 endforeach;
5443 endif; ?>
5444 </div>
5445 </div>
5446
5447 <div class="mxchat-modal-actions">
5448 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
5449 <?php esc_html_e('Cancel', 'mxchat'); ?>
5450 </button>
5451 <button type="submit" class="mxchat-button-primary" id="mxchat-save-action-btn">
5452 <?php esc_html_e('Save Action', 'mxchat'); ?>
5453 </button>
5454 </div>
5455 </div>
5456 </form>
5457 </div>
5458 </div>
5459
5460 <div id="mxchat-action-loading" class="mxchat-action-loading" style="display: none;">
5461 <div class="mxchat-action-loading-spinner"></div>
5462 <div class="mxchat-action-loading-text">
5463 <?php esc_html_e('Saving action, please wait...', 'mxchat'); ?>
5464 </div>
5465 </div>
5466 </div><!-- .mxchat-wrapper -->
5467 <?php
5468 }
5469 private function get_trimmed_phrases($phrases, $max_length = 100) {
5470 if (strlen($phrases) <= $max_length) {
5471 return $phrases;
5472 }
5473
5474 $trimmed = substr($phrases, 0, $max_length);
5475 $last_comma = strrpos($trimmed, ',');
5476
5477 if ($last_comma !== false) {
5478 $trimmed = substr($trimmed, 0, $last_comma);
5479 }
5480
5481 return $trimmed . '...';
5482 }
5483 public function mxchat_add_enabled_column_to_intents() {
5484 global $wpdb;
5485 $table_name = $wpdb->prefix . 'mxchat_intents';
5486
5487 // Check if the column already exists
5488 $columns = $wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'enabled'");
5489
5490 if (empty($columns)) {
5491 // Add the column with default value of 1 (enabled)
5492 $wpdb->query("ALTER TABLE $table_name ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1");
5493 }
5494 }
5495
5496 public function mxchat_handle_delete_intent() {
5497 if ( ! current_user_can( 'manage_options' ) ) {
5498 wp_die( esc_html__('Unauthorized user', 'mxchat') );
5499 }
5500
5501 check_admin_referer('mxchat_delete_intent_nonce');
5502
5503 if (isset($_POST['intent_id'])) {
5504 global $wpdb;
5505 $table_name = $wpdb->prefix . 'mxchat_intents';
5506 $intent_id = intval($_POST['intent_id']);
5507
5508 $wpdb->delete($table_name, ['id' => $intent_id], ['%d']);
5509 }
5510
5511 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5512 exit;
5513 }
5514 public function mxchat_handle_edit_intent() {
5515 // Security checks (nonce and permissions)
5516 if (!current_user_can('manage_options')) {
5517 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5518 }
5519 check_admin_referer('mxchat_edit_intent');
5520
5521 // Get POST data
5522 $intent_id = isset($_POST['intent_id']) ? absint($_POST['intent_id']) : 0;
5523 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5524 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5525 $threshold_percentage = isset($_POST['similarity_threshold']) ? intval($_POST['similarity_threshold']) : 85;
5526 $similarity_threshold = min(95, max(10, $threshold_percentage)) / 100; // Convert to 0.10–0.95
5527
5528 // Handle enabled_bots
5529 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5530 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5531
5532 // Ensure default is always included for backward compatibility
5533 if (!in_array('default', $enabled_bots)) {
5534 $enabled_bots[] = 'default';
5535 }
5536
5537 $enabled_bots_json = json_encode($enabled_bots);
5538
5539 // Validate inputs
5540 if (!$intent_id || empty($intent_label) || empty($phrases_input)) {
5541 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5542 return;
5543 }
5544
5545 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5546 if (empty($phrases_array)) {
5547 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5548 return;
5549 }
5550
5551 // Generate embeddings with improved error handling
5552 $vectors = [];
5553 $failed_phrases = [];
5554
5555 foreach ($phrases_array as $phrase) {
5556 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5557 if (is_array($embedding_vector)) {
5558 $vectors[] = $embedding_vector;
5559 } else {
5560 $failed_phrases[] = $phrase;
5561 }
5562 }
5563
5564 if (!empty($failed_phrases)) {
5565 $this->handle_embedding_error(
5566 sprintf(
5567 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5568 implode(', ', $failed_phrases)
5569 )
5570 );
5571 return;
5572 }
5573
5574 if (empty($vectors)) {
5575 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5576 return;
5577 }
5578
5579 $combined_vector = $this->mxchat_average_vectors($vectors);
5580 $serialized_vector = maybe_serialize($combined_vector);
5581
5582 // Update the database
5583 global $wpdb;
5584 $table_name = $wpdb->prefix . 'mxchat_intents';
5585
5586 $result = $wpdb->update(
5587 $table_name,
5588 array(
5589 'intent_label' => $intent_label,
5590 'phrases' => implode(', ', $phrases_array),
5591 'embedding_vector' => $serialized_vector,
5592 'similarity_threshold' => $similarity_threshold,
5593 'enabled_bots' => $enabled_bots_json, // Include enabled_bots in update
5594 ),
5595 array('id' => $intent_id),
5596 array('%s', '%s', '%s', '%f', '%s'), // Format: string, string, string, float, string
5597 array('%d') // Where format: integer
5598 );
5599
5600 if (false === $result) {
5601 $this->handle_embedding_error(__('Failed to update action in database.', 'mxchat'));
5602 return;
5603 }
5604
5605 // Set success message and redirect
5606 set_transient('mxchat_admin_notice_success', __('Intent updated successfully!', 'mxchat'), 60);
5607
5608 $redirect_url = add_query_arg(
5609 array(
5610 'page' => 'mxchat-actions'
5611 ),
5612 admin_url('admin.php')
5613 );
5614 wp_safe_redirect($redirect_url);
5615 exit;
5616 }
5617 public function mxchat_handle_add_intent() {
5618 if (!current_user_can('manage_options')) {
5619 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5620 }
5621 check_admin_referer('mxchat_add_intent_nonce');
5622 global $wpdb;
5623 $table_name = $wpdb->prefix . 'mxchat_intents';
5624
5625 // Sanitize and get form data
5626 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5627 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5628 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5629
5630 // Get similarity threshold from form (convert percentage to decimal)
5631 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5632
5633 // Handle enabled_bots
5634 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5635 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5636
5637 // Ensure default is always included for backward compatibility with existing actions
5638 if (!in_array('default', $enabled_bots)) {
5639 $enabled_bots[] = 'default';
5640 }
5641
5642 $enabled_bots_json = json_encode($enabled_bots);
5643
5644 // Validate required fields
5645 if (empty($intent_label) || empty($callback_function) || empty($phrases_input)) {
5646 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5647 return;
5648 }
5649
5650 // Validate callback function
5651 $available_callbacks = $this->mxchat_get_available_callbacks();
5652 if (!array_key_exists($callback_function, $available_callbacks)) {
5653 $this->handle_embedding_error(__('Invalid callback function selected.', 'mxchat'));
5654 return;
5655 }
5656
5657 // Check if this is an add-on promotional placeholder (not a real action)
5658 if (!empty($available_callbacks[$callback_function]['addon_promo'])) {
5659 $addon_name = isset($available_callbacks[$callback_function]['addon_name']) ? $available_callbacks[$callback_function]['addon_name'] : __('an add-on', 'mxchat');
5660 $this->handle_embedding_error(sprintf(
5661 __('This action requires the %s to be installed and activated.', 'mxchat'),
5662 $addon_name
5663 ));
5664 return;
5665 }
5666
5667 // Process phrases
5668 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5669 if (empty($phrases_array)) {
5670 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5671 return;
5672 }
5673
5674 // Generate embeddings with improved error handling
5675 $vectors = [];
5676 $failed_phrases = [];
5677 foreach ($phrases_array as $phrase) {
5678 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5679 if (is_array($embedding_vector)) {
5680 $vectors[] = $embedding_vector;
5681 } else {
5682 $failed_phrases[] = $phrase;
5683 }
5684 }
5685
5686 // Check for embedding failures
5687 if (!empty($failed_phrases)) {
5688 $this->handle_embedding_error(
5689 sprintf(
5690 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5691 implode(', ', $failed_phrases)
5692 )
5693 );
5694 return;
5695 }
5696
5697 if (empty($vectors)) {
5698 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5699 return;
5700 }
5701
5702 // Create combined vector and insert into database
5703 $combined_vector = $this->mxchat_average_vectors($vectors);
5704 $serialized_vector = maybe_serialize($combined_vector);
5705
5706 $result = $wpdb->insert($table_name, [
5707 'intent_label' => $intent_label,
5708 'phrases' => implode(', ', $phrases_array),
5709 'embedding_vector' => $serialized_vector,
5710 'callback_function' => $callback_function,
5711 'similarity_threshold' => $similarity_threshold,
5712 'enabled_bots' => $enabled_bots_json, // NEW field
5713 ]);
5714
5715 if ($result === false) {
5716 $this->handle_embedding_error(__('Database error: ', 'mxchat') . $wpdb->last_error);
5717 return;
5718 }
5719
5720 // Set success message and redirect
5721 set_transient('mxchat_admin_notice_success', __('New intent added successfully!', 'mxchat'), 60);
5722 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5723 exit;
5724 }
5725
5726
5727
5728
5729 private function handle_embedding_error($message, $redirect = true) {
5730 // Store the error message in the existing transient
5731 set_transient('mxchat_admin_notice_error', $message, 60);
5732
5733 if ($redirect) {
5734 // Redirect back to the actions page
5735 $redirect_url = add_query_arg(
5736 array(
5737 'page' => 'mxchat-actions'
5738 ),
5739 admin_url('admin.php')
5740 );
5741 wp_safe_redirect($redirect_url);
5742 exit;
5743 }
5744 }
5745
5746 /**
5747 * AJAX handler to fetch actions list for the new split-panel UI
5748 */
5749 public function mxchat_fetch_actions_list() {
5750 check_ajax_referer('mxchat_actions_nonce', 'security');
5751
5752 if (!current_user_can('manage_options')) {
5753 wp_send_json_error(__('Unauthorized', 'mxchat'));
5754 }
5755
5756 global $wpdb;
5757 $table_name = $wpdb->prefix . 'mxchat_intents';
5758
5759 $page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
5760 $per_page = isset($_POST['per_page']) ? min(100, max(1, intval($_POST['per_page']))) : 50;
5761 $offset = ($page - 1) * $per_page;
5762 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
5763 $callback_filter = isset($_POST['callback_filter']) ? sanitize_text_field($_POST['callback_filter']) : '';
5764 $sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] === 'asc' ? 'ASC' : 'DESC';
5765
5766 // Build WHERE clause
5767 $where = '1=1';
5768 $params = array();
5769
5770 if ($search) {
5771 $search_like = '%' . $wpdb->esc_like($search) . '%';
5772 $where .= ' AND (intent_label LIKE %s OR phrases LIKE %s)';
5773 $params[] = $search_like;
5774 $params[] = $search_like;
5775 }
5776
5777 if ($callback_filter) {
5778 $where .= ' AND callback_function = %s';
5779 $params[] = $callback_filter;
5780 }
5781
5782 // Get total count
5783 $count_query = "SELECT COUNT(*) FROM $table_name WHERE $where";
5784 if (!empty($params)) {
5785 $count_query = $wpdb->prepare($count_query, $params);
5786 }
5787 $total_actions = $wpdb->get_var($count_query);
5788
5789 // Get actions
5790 $query = "SELECT * FROM $table_name WHERE $where ORDER BY id $sort_order LIMIT %d OFFSET %d";
5791 $all_params = array_merge($params, array($per_page, $offset));
5792 $actions = $wpdb->get_results($wpdb->prepare($query, $all_params));
5793
5794 // Get available callbacks for labels/icons
5795 $available_callbacks = $this->mxchat_get_available_callbacks();
5796
5797 // Prefetch individual phrase counts for all fetched actions
5798 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5799 $phrase_counts = array();
5800 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5801 $action_ids = wp_list_pluck($actions, 'id');
5802 if (!empty($action_ids)) {
5803 $id_placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5804 $count_results = $wpdb->get_results($wpdb->prepare(
5805 "SELECT intent_id, COUNT(*) as cnt FROM $phrases_table WHERE intent_id IN ($id_placeholders) GROUP BY intent_id",
5806 $action_ids
5807 ));
5808 foreach ($count_results as $row) {
5809 $phrase_counts[$row->intent_id] = intval($row->cnt);
5810 }
5811 }
5812 }
5813
5814 // Format actions for response
5815 $formatted_actions = array();
5816 foreach ($actions as $action) {
5817 $callback_data = isset($available_callbacks[$action->callback_function])
5818 ? $available_callbacks[$action->callback_function]
5819 : array('label' => $action->callback_function, 'icon' => 'admin-generic');
5820
5821 $enabled_bots = json_decode($action->enabled_bots, true);
5822 if (!is_array($enabled_bots)) {
5823 $enabled_bots = array('default');
5824 }
5825
5826 $formatted_actions[] = array(
5827 'id' => intval($action->id),
5828 'label' => $action->intent_label,
5829 'phrases' => $action->phrases,
5830 'callback_function' => $action->callback_function,
5831 'callback_label' => $callback_data['label'],
5832 'icon' => isset($callback_data['icon']) ? $callback_data['icon'] : 'admin-generic',
5833 'threshold' => round($action->similarity_threshold * 100),
5834 'enabled' => (bool) $action->enabled,
5835 'enabled_bots' => $enabled_bots,
5836 'has_legacy_vector' => !empty($action->embedding_vector),
5837 'individual_phrase_count' => isset($phrase_counts[$action->id]) ? $phrase_counts[$action->id] : 0,
5838 );
5839 }
5840
5841 $total_pages = ceil($total_actions / $per_page);
5842 $showing_start = $total_actions > 0 ? $offset + 1 : 0;
5843 $showing_end = min($offset + $per_page, $total_actions);
5844
5845 wp_send_json_success(array(
5846 'actions' => $formatted_actions,
5847 'page' => $page,
5848 'per_page' => $per_page,
5849 'total_actions' => intval($total_actions),
5850 'total_pages' => $total_pages,
5851 'showing_start' => $showing_start,
5852 'showing_end' => $showing_end,
5853 ));
5854 }
5855
5856 /**
5857 * AJAX handler to toggle action enabled status
5858 */
5859 public function mxchat_toggle_action_status() {
5860 check_ajax_referer('mxchat_actions_nonce', 'security');
5861
5862 if (!current_user_can('manage_options')) {
5863 wp_send_json_error(__('Unauthorized', 'mxchat'));
5864 }
5865
5866 $action_id = isset($_POST['action_id']) ? intval($_POST['action_id']) : 0;
5867 $enabled = isset($_POST['enabled']) ? intval($_POST['enabled']) : 0;
5868
5869 if (!$action_id) {
5870 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5871 }
5872
5873 global $wpdb;
5874 $table_name = $wpdb->prefix . 'mxchat_intents';
5875
5876 $result = $wpdb->update(
5877 $table_name,
5878 array('enabled' => $enabled ? 1 : 0),
5879 array('id' => $action_id),
5880 array('%d'),
5881 array('%d')
5882 );
5883
5884 if ($result === false) {
5885 wp_send_json_error(__('Failed to update action status', 'mxchat'));
5886 }
5887
5888 wp_send_json_success(array('enabled' => (bool) $enabled));
5889 }
5890
5891 /**
5892 * AJAX handler to bulk delete actions
5893 */
5894 public function mxchat_bulk_delete_actions() {
5895 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5896
5897 if (!current_user_can('manage_options')) {
5898 wp_send_json_error(__('Unauthorized', 'mxchat'));
5899 }
5900
5901 $action_ids = isset($_POST['action_ids']) ? array_map('intval', (array) $_POST['action_ids']) : array();
5902
5903 if (empty($action_ids)) {
5904 wp_send_json_error(__('No actions selected', 'mxchat'));
5905 }
5906
5907 global $wpdb;
5908 $table_name = $wpdb->prefix . 'mxchat_intents';
5909
5910 $placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5911 $query = $wpdb->prepare("DELETE FROM $table_name WHERE id IN ($placeholders)", $action_ids);
5912 $result = $wpdb->query($query);
5913
5914 if ($result === false) {
5915 wp_send_json_error(__('Failed to delete actions', 'mxchat'));
5916 }
5917
5918 // Also delete individual phrases for these intents
5919 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5920 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5921 $wpdb->query($wpdb->prepare("DELETE FROM $phrases_table WHERE intent_id IN ($placeholders)", $action_ids));
5922 }
5923
5924 wp_send_json_success(array('deleted' => $result));
5925 }
5926
5927 /**
5928 * AJAX handler to add a new intent/action
5929 */
5930 public function mxchat_add_intent_ajax() {
5931 check_ajax_referer('mxchat_add_intent_nonce', 'security');
5932
5933 if (!current_user_can('manage_options')) {
5934 wp_send_json_error(__('Unauthorized', 'mxchat'));
5935 }
5936
5937 global $wpdb;
5938 $table_name = $wpdb->prefix . 'mxchat_intents';
5939
5940 // Sanitize input
5941 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5942 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5943 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5944 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5945 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5946
5947 // Validate
5948 // Check if using new individual phrases mode or legacy mode
5949 $individual_phrases = isset($_POST['individual_phrases']) ? array_filter(array_map('sanitize_text_field', (array) $_POST['individual_phrases'])) : array();
5950 $use_individual = !empty($individual_phrases);
5951
5952 // Validate required fields (phrases not required when using individual mode)
5953 if (empty($intent_label) || empty($callback_function)) {
5954 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5955 }
5956 if (!$use_individual && empty($phrases_input)) {
5957 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5958 }
5959
5960 // Ensure default bot is included
5961 if (!in_array('default', $enabled_bots)) {
5962 $enabled_bots[] = 'default';
5963 }
5964 $enabled_bots_json = json_encode($enabled_bots);
5965
5966 if ($use_individual) {
5967 // New mode: individual phrases each get their own vector
5968 // Insert the intent row with empty legacy fields
5969 $result = $wpdb->insert(
5970 $table_name,
5971 array(
5972 'intent_label' => $intent_label,
5973 'phrases' => '',
5974 'embedding_vector' => '',
5975 'similarity_threshold' => $similarity_threshold,
5976 'callback_function' => $callback_function,
5977 'enabled' => 1,
5978 'enabled_bots' => $enabled_bots_json,
5979 )
5980 );
5981
5982 if ($result === false) {
5983 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5984 }
5985
5986 $intent_id = $wpdb->insert_id;
5987
5988 // Insert each phrase individually with its own embedding
5989 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5990 $failed_phrases = array();
5991 foreach ($individual_phrases as $phrase) {
5992 $phrase = trim($phrase);
5993 if (empty($phrase)) continue;
5994
5995 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5996 if (is_wp_error($embedding_vector)) {
5997 $failed_phrases[] = $phrase;
5998 continue;
5999 }
6000
6001 $wpdb->insert(
6002 $phrases_table,
6003 array(
6004 'intent_id' => $intent_id,
6005 'phrase' => $phrase,
6006 'embedding_vector' => maybe_serialize($embedding_vector),
6007 )
6008 );
6009 }
6010
6011 $response = array('id' => $intent_id);
6012 if (!empty($failed_phrases)) {
6013 $response['failed_phrases'] = $failed_phrases;
6014 }
6015 wp_send_json_success($response);
6016
6017 } else {
6018 // Legacy mode: combine all phrases into one embedding (backwards compatible)
6019 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
6020 if (empty($phrases_array)) {
6021 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
6022 }
6023
6024 // Generate embedding (combine phrases into single string for embedding)
6025 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
6026 if (is_wp_error($embedding_vector)) {
6027 // Fallback: store without embedding
6028 $serialized_vector = null;
6029 } else {
6030 $serialized_vector = maybe_serialize($embedding_vector);
6031 }
6032
6033 // Insert
6034 $result = $wpdb->insert(
6035 $table_name,
6036 array(
6037 'intent_label' => $intent_label,
6038 'phrases' => implode(', ', $phrases_array),
6039 'embedding_vector' => $serialized_vector,
6040 'similarity_threshold' => $similarity_threshold,
6041 'callback_function' => $callback_function,
6042 'enabled' => 1,
6043 'enabled_bots' => $enabled_bots_json,
6044 )
6045 );
6046
6047 if ($result === false) {
6048 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
6049 }
6050
6051 wp_send_json_success(array('id' => $wpdb->insert_id));
6052 }
6053 }
6054
6055 /**
6056 * AJAX handler to edit an existing intent/action
6057 */
6058 public function mxchat_edit_intent_ajax() {
6059 check_ajax_referer('mxchat_edit_intent', 'security');
6060
6061 if (!current_user_can('manage_options')) {
6062 wp_send_json_error(__('Unauthorized', 'mxchat'));
6063 }
6064
6065 global $wpdb;
6066 $table_name = $wpdb->prefix . 'mxchat_intents';
6067
6068 // Sanitize input
6069 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6070 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
6071 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
6072 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
6073 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
6074 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
6075
6076 // Validate
6077 if (!$intent_id || empty($intent_label)) {
6078 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
6079 }
6080
6081 // Check if phrases are managed individually (empty phrases_input means individual mode)
6082 $uses_individual_phrases = empty($phrases_input);
6083
6084 if ($uses_individual_phrases) {
6085 // Individual phrase mode: only update non-phrase fields, skip embedding regeneration
6086 $update_data = array(
6087 'intent_label' => $intent_label,
6088 'similarity_threshold' => $similarity_threshold,
6089 'callback_function' => $callback_function,
6090 'enabled_bots' => json_encode($enabled_bots),
6091 );
6092 } else {
6093 // Legacy mode: process phrases and regenerate embedding (backwards compatible for add-ons)
6094 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
6095 if (empty($phrases_array)) {
6096 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
6097 }
6098
6099 // Generate new embedding (combine phrases into single string for embedding)
6100 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
6101 if (is_wp_error($embedding_vector)) {
6102 // Keep existing embedding
6103 $update_data = array(
6104 'intent_label' => $intent_label,
6105 'phrases' => implode(', ', $phrases_array),
6106 'similarity_threshold' => $similarity_threshold,
6107 'callback_function' => $callback_function,
6108 'enabled_bots' => json_encode($enabled_bots),
6109 );
6110 } else {
6111 $serialized_vector = maybe_serialize($embedding_vector);
6112 $update_data = array(
6113 'intent_label' => $intent_label,
6114 'phrases' => implode(', ', $phrases_array),
6115 'embedding_vector' => $serialized_vector,
6116 'similarity_threshold' => $similarity_threshold,
6117 'callback_function' => $callback_function,
6118 'enabled_bots' => json_encode($enabled_bots),
6119 );
6120 }
6121 }
6122
6123 // Ensure default bot is included
6124 if (!in_array('default', $enabled_bots)) {
6125 $enabled_bots[] = 'default';
6126 }
6127 $update_data['enabled_bots'] = json_encode($enabled_bots);
6128
6129 $result = $wpdb->update(
6130 $table_name,
6131 $update_data,
6132 array('id' => $intent_id),
6133 null,
6134 array('%d')
6135 );
6136
6137 if ($result === false) {
6138 wp_send_json_error(__('Failed to update action.', 'mxchat'));
6139 }
6140
6141 wp_send_json_success(array('updated' => true));
6142 }
6143
6144 /**
6145 * AJAX handler to delete a single intent/action
6146 */
6147 public function mxchat_delete_intent_ajax() {
6148 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
6149
6150 if (!current_user_can('manage_options')) {
6151 wp_send_json_error(__('Unauthorized', 'mxchat'));
6152 }
6153
6154 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6155
6156 if (!$intent_id) {
6157 wp_send_json_error(__('Invalid action ID', 'mxchat'));
6158 }
6159
6160 global $wpdb;
6161 $table_name = $wpdb->prefix . 'mxchat_intents';
6162
6163 $result = $wpdb->delete($table_name, array('id' => $intent_id), array('%d'));
6164
6165 if ($result === false) {
6166 wp_send_json_error(__('Failed to delete action', 'mxchat'));
6167 }
6168
6169 // Also delete individual phrases for this intent
6170 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6171 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
6172 $wpdb->delete($phrases_table, array('intent_id' => $intent_id), array('%d'));
6173 }
6174
6175 wp_send_json_success(array('deleted' => true));
6176 }
6177
6178 /**
6179 * AJAX handler to add a single phrase with its own embedding to an intent
6180 */
6181 public function mxchat_add_phrase_ajax() {
6182 check_ajax_referer('mxchat_add_phrase_nonce', 'security');
6183
6184 if (!current_user_can('manage_options')) {
6185 wp_send_json_error(__('Unauthorized', 'mxchat'));
6186 }
6187
6188 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6189 $phrase = isset($_POST['phrase']) ? sanitize_text_field($_POST['phrase']) : '';
6190
6191 if (!$intent_id || empty($phrase)) {
6192 wp_send_json_error(__('Please provide an intent ID and phrase.', 'mxchat'));
6193 }
6194
6195 // Verify the intent exists
6196 global $wpdb;
6197 $intents_table = $wpdb->prefix . 'mxchat_intents';
6198 $intent = $wpdb->get_row($wpdb->prepare("SELECT id FROM $intents_table WHERE id = %d", $intent_id));
6199 if (!$intent) {
6200 wp_send_json_error(__('Action not found.', 'mxchat'));
6201 }
6202
6203 // Generate embedding for this single phrase
6204 $embedding_vector = $this->mxchat_generate_embedding($phrase);
6205 if (is_wp_error($embedding_vector)) {
6206 wp_send_json_error(__('Failed to generate embedding: ', 'mxchat') . $embedding_vector->get_error_message());
6207 }
6208
6209 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6210 $result = $wpdb->insert(
6211 $phrases_table,
6212 array(
6213 'intent_id' => $intent_id,
6214 'phrase' => $phrase,
6215 'embedding_vector' => maybe_serialize($embedding_vector),
6216 )
6217 );
6218
6219 if ($result === false) {
6220 wp_send_json_error(__('Failed to add phrase.', 'mxchat'));
6221 }
6222
6223 wp_send_json_success(array('id' => $wpdb->insert_id, 'phrase' => $phrase));
6224 }
6225
6226 /**
6227 * AJAX handler to delete a single phrase from wp_mxchat_intent_phrases
6228 */
6229 public function mxchat_delete_phrase_ajax() {
6230 check_ajax_referer('mxchat_delete_phrase_nonce', 'security');
6231
6232 if (!current_user_can('manage_options')) {
6233 wp_send_json_error(__('Unauthorized', 'mxchat'));
6234 }
6235
6236 $phrase_id = isset($_POST['phrase_id']) ? intval($_POST['phrase_id']) : 0;
6237 if (!$phrase_id) {
6238 wp_send_json_error(__('Invalid phrase ID.', 'mxchat'));
6239 }
6240
6241 global $wpdb;
6242 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6243 $result = $wpdb->delete($phrases_table, array('id' => $phrase_id), array('%d'));
6244
6245 if ($result === false) {
6246 wp_send_json_error(__('Failed to delete phrase.', 'mxchat'));
6247 }
6248
6249 wp_send_json_success(array('deleted' => true));
6250 }
6251
6252 /**
6253 * AJAX handler to fetch individual phrases for an intent
6254 */
6255 public function mxchat_get_phrases_ajax() {
6256 check_ajax_referer('mxchat_get_phrases_nonce', 'security');
6257
6258 if (!current_user_can('manage_options')) {
6259 wp_send_json_error(__('Unauthorized', 'mxchat'));
6260 }
6261
6262 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6263 if (!$intent_id) {
6264 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
6265 }
6266
6267 global $wpdb;
6268 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6269
6270 $phrases = array();
6271 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
6272 $phrases = $wpdb->get_results($wpdb->prepare(
6273 "SELECT id, phrase, created_at FROM $phrases_table WHERE intent_id = %d ORDER BY created_at ASC",
6274 $intent_id
6275 ));
6276 }
6277
6278 wp_send_json_success(array('phrases' => $phrases));
6279 }
6280
6281 /**
6282 * AJAX handler to clear legacy phrases and embedding from the main intents table
6283 */
6284 public function mxchat_delete_legacy_phrases_ajax() {
6285 check_ajax_referer('mxchat_delete_legacy_nonce', 'security');
6286
6287 if (!current_user_can('manage_options')) {
6288 wp_send_json_error(__('Unauthorized', 'mxchat'));
6289 }
6290
6291 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6292 if (!$intent_id) {
6293 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
6294 }
6295
6296 global $wpdb;
6297 $table_name = $wpdb->prefix . 'mxchat_intents';
6298
6299 $result = $wpdb->update(
6300 $table_name,
6301 array('phrases' => '', 'embedding_vector' => ''),
6302 array('id' => $intent_id),
6303 array('%s', '%s'),
6304 array('%d')
6305 );
6306
6307 if ($result === false) {
6308 wp_send_json_error(__('Failed to clear legacy phrases.', 'mxchat'));
6309 }
6310
6311 wp_send_json_success(array('cleared' => true));
6312 }
6313
6314 /**
6315 * Enhanced get_available_callbacks function with form action exclusion
6316 *
6317 * @param bool $grouped Whether to return callbacks grouped by category
6318 * @param bool $include_all Whether to include all potential actions (even if add-on not installed)
6319 * @return array Callbacks data with icons, descriptions and availability status
6320 */
6321 private function mxchat_get_available_callbacks($grouped = false, $include_all = true) {
6322 // Load WordPress plugin functions if needed
6323 if (!function_exists('get_plugins')) {
6324 require_once ABSPATH . 'wp-admin/includes/plugin.php';
6325 }
6326
6327 // Get active plugins
6328 $active_plugins = get_option('active_plugins', array());
6329
6330 // Functions to exclude from the action selector only if Pro is activated
6331 // If user doesn't have Pro, show these so they can see what they're missing
6332 $excluded_when_pro_active_functions = array(
6333 'mxchat_handle_form_collection' // Forms add-on action
6334 );
6335
6336 // Always excluded functions (regardless of Pro status)
6337 $always_excluded_functions = array();
6338
6339 // Combine exclusion lists based on Pro activation status
6340 $excluded_functions = $always_excluded_functions;
6341 if ($this->is_activated) {
6342 // Only exclude add-on managed functions if Pro is active
6343 $excluded_functions = array_merge($excluded_functions, $excluded_when_pro_active_functions);
6344 }
6345
6346 // Define add-on plugin files and their corresponding action functions
6347 $addon_plugins = array(
6348 'mxchat-woo/mxchat-woo.php' => array(
6349 'functions' => array(
6350 'mxchat_handle_product_recommendations',
6351 'mxchat_handle_order_history',
6352 'mxchat_show_product_card',
6353 'mxchat_add_to_cart',
6354 'mxchat_checkout_redirect',
6355 'mxchat_handle_featured_products'
6356 ),
6357 'name' => __('WooCommerce Add-on', 'mxchat'),
6358 'pro_required' => true
6359 ),
6360 'mxchat-perplexity/mxchat-perplexity.php' => array(
6361 'functions' => array('mxchat_perplexity_research'),
6362 'name' => __('Perplexity Add-on', 'mxchat'),
6363 'pro_required' => true
6364 ),
6365 'mxchat-forms/mxchat-forms.php' => array(
6366 'functions' => array('mxchat_handle_form_collection'),
6367 'name' => __('Forms Add-on', 'mxchat'),
6368 'pro_required' => true
6369 ),
6370 // Add other add-ons and their functions here
6371 );
6372
6373 // Get the functions that are provided by active add-ons
6374 $addon_provided_functions = array();
6375 $addon_function_mapping = array(); // Maps functions to their add-on info
6376
6377 // Check which add-ons are active
6378 foreach ($addon_plugins as $plugin_file => $addon_info) {
6379 $is_active = in_array($plugin_file, $active_plugins);
6380
6381 // For each function in this addon
6382 foreach ($addon_info['functions'] as $function) {
6383 // Consider a function installed only if:
6384 // 1. The add-on is active AND
6385 // 2. Either it doesn't require Pro OR Pro is activated
6386 $is_installed = $is_active && (!$addon_info['pro_required'] || $this->is_activated);
6387
6388 // If the add-on is installed, mark this function as provided by an add-on
6389 if ($is_installed) {
6390 $addon_provided_functions[] = $function;
6391 }
6392
6393 // Store addon info for this function regardless of installation status
6394 $addon_function_mapping[$function] = array(
6395 'addon' => basename(dirname($plugin_file)),
6396 'addon_name' => $addon_info['name'],
6397 'pro_required' => $addon_info['pro_required'],
6398 'is_active' => $is_active,
6399 'is_installed' => $is_installed
6400 );
6401 }
6402 }
6403
6404 // Core callbacks - always available in the base plugin
6405 $core_callbacks = array(
6406 'mxchat_handle_email_capture' => array(
6407 'label' => __('Loops Email Capture', 'mxchat'),
6408 'pro_only' => false,
6409 'group' => __('Customer Engagement', 'mxchat'),
6410 'icon' => 'email-alt',
6411 'description' => __('Collect visitor emails for your mailing list in Loops', 'mxchat'),
6412 'addon' => false, // Not from an add-on
6413 'installed' => true // Always installed with base plugin
6414 ),
6415 'mxchat_handle_search_request' => array(
6416 'label' => __('Brave Web Search', 'mxchat'),
6417 'pro_only' => false,
6418 'group' => __('Search Features', 'mxchat'),
6419 'icon' => 'search',
6420 'description' => __('Let users search the web directly from the chat (requires a Brave Search API key)', 'mxchat'),
6421 'addon' => false,
6422 'installed' => true
6423 ),
6424 'mxchat_handle_image_search_request' => array(
6425 'label' => __('Brave Image Search', 'mxchat'),
6426 'pro_only' => false,
6427 'group' => __('Search Features', 'mxchat'),
6428 'icon' => 'format-image',
6429 'description' => __('Search and display images in the chat conversation (requires a Brave Search API key)', 'mxchat'),
6430 'addon' => false,
6431 'installed' => true
6432 ),
6433 // Pro core features - check is_activated property
6434 'mxchat_generate_image' => array(
6435 'label' => __('Generate Image (OpenAI)', 'mxchat'),
6436 'pro_only' => false,
6437 'group' => __('Other Features', 'mxchat'),
6438 'icon' => 'art',
6439 'description' => __('Create images with GPT Image from OpenAI (requires OpenAI API key)', 'mxchat'),
6440 'addon' => false,
6441 'installed' => true
6442 ),
6443 'mxchat_generate_gemini_image' => array(
6444 'label' => __('Generate Image (Gemini)', 'mxchat'),
6445 'pro_only' => false,
6446 'group' => __('Other Features', 'mxchat'),
6447 'icon' => 'art',
6448 'description' => __('Create images with Imagen from Google (requires Gemini API key)', 'mxchat'),
6449 'addon' => false,
6450 'installed' => true
6451 ),
6452 'mxchat_handle_pdf_discussion' => array(
6453 'label' => __('Chat with PDF', 'mxchat'),
6454 'pro_only' => false,
6455 'group' => __('Other Features', 'mxchat'),
6456 'icon' => 'media-document',
6457 'description' => __('Answer questions about uploaded PDF documents', 'mxchat'),
6458 'addon' => false,
6459 'installed' => true
6460 ),
6461 'mxchat_live_agent_handover' => array(
6462 'label' => __('Slack Live Agent', 'mxchat'),
6463 'pro_only' => false,
6464 'group' => __('Customer Engagement', 'mxchat'),
6465 'icon' => 'admin-users',
6466 'description' => __('Transfer conversation to a human support agent on Slack', 'mxchat'),
6467 'addon' => false,
6468 'installed' => true
6469 ),
6470 'mxchat_telegram_live_agent_handover' => array(
6471 'label' => __('Telegram Live Agent', 'mxchat'),
6472 'pro_only' => false,
6473 'group' => __('Customer Engagement', 'mxchat'),
6474 'icon' => 'format-chat',
6475 'description' => __('Transfer conversation to a human support agent on Telegram', 'mxchat'),
6476 'addon' => false,
6477 'installed' => true
6478 ),
6479 'mxchat_webhook_live_agent_handover' => array(
6480 'label' => __('Webhook Live Agent', 'mxchat'),
6481 'pro_only' => false,
6482 'group' => __('Customer Engagement', 'mxchat'),
6483 'icon' => 'external',
6484 'description' => __('Send the conversation to your own webhook URL so your team can follow up', 'mxchat'),
6485 'addon' => false,
6486 'installed' => true
6487 ),
6488 'mxchat_handle_switch_to_chatbot_intent' => array(
6489 'label' => __('Back to Chatbot', 'mxchat'),
6490 'pro_only' => false,
6491 'group' => __('Customer Engagement', 'mxchat'),
6492 'icon' => 'backup',
6493 'description' => __('Return from live agent mode to AI chatbot', 'mxchat'),
6494 'addon' => false,
6495 'installed' => true
6496 ),
6497 );
6498
6499 // Add-on callbacks with placeholders - only include if the add-on is NOT active
6500 // These are promotional/informational only — not selectable as real actions
6501 $addon_callbacks = array(
6502 // WooCommerce Add-on
6503 'mxchat_handle_product_recommendations' => array(
6504 'label' => __('Product Recommendations', 'mxchat'),
6505 'pro_only' => false,
6506 'addon_promo' => true,
6507 'group' => __('WooCommerce Features', 'mxchat'),
6508 'icon' => 'cart',
6509 'description' => __('Suggest products based on customer preferences', 'mxchat'),
6510 ),
6511 'mxchat_handle_order_history' => array(
6512 'label' => __('Order History', 'mxchat'),
6513 'pro_only' => false,
6514 'addon_promo' => true,
6515 'group' => __('WooCommerce Features', 'mxchat'),
6516 'icon' => 'clipboard',
6517 'description' => __('Allow customers to check their order status', 'mxchat'),
6518 ),
6519 'mxchat_show_product_card' => array(
6520 'label' => __('Show Product Card', 'mxchat'),
6521 'pro_only' => false,
6522 'addon_promo' => true,
6523 'group' => __('WooCommerce Features', 'mxchat'),
6524 'icon' => 'products',
6525 'description' => __('Display product information in the chat', 'mxchat'),
6526 ),
6527 'mxchat_add_to_cart' => array(
6528 'label' => __('Add to Cart', 'mxchat'),
6529 'pro_only' => false,
6530 'addon_promo' => true,
6531 'group' => __('WooCommerce Features', 'mxchat'),
6532 'icon' => 'plus-alt',
6533 'description' => __('Add products to cart directly from chat', 'mxchat'),
6534 ),
6535 'mxchat_checkout_redirect' => array(
6536 'label' => __('Proceed to Checkout', 'mxchat'),
6537 'pro_only' => false,
6538 'addon_promo' => true,
6539 'group' => __('WooCommerce Features', 'mxchat'),
6540 'icon' => 'arrow-right-alt',
6541 'description' => __('Redirect customer to checkout page', 'mxchat'),
6542 ),
6543 'mxchat_handle_featured_products' => array(
6544 'label' => __('Featured Products Showcase', 'mxchat'),
6545 'pro_only' => false,
6546 'addon_promo' => true,
6547 'group' => __('WooCommerce Features', 'mxchat'),
6548 'icon' => 'star-filled',
6549 'description' => __('Display a curated selection of products with an AI-generated message', 'mxchat'),
6550 ),
6551
6552 // Perplexity Add-on
6553 'mxchat_perplexity_research' => array(
6554 'label' => __('Perplexity Research', 'mxchat'),
6555 'pro_only' => false,
6556 'addon_promo' => true,
6557 'group' => __('Search Features', 'mxchat'),
6558 'icon' => 'book-alt',
6559 'description' => __('Allows the chatbot to search the web for accurate, up-to-date answers', 'mxchat'),
6560 ),
6561
6562 // Forms Add-on
6563 'mxchat_handle_form_collection' => array(
6564 'label' => __('Form Collection', 'mxchat'),
6565 'pro_only' => false,
6566 'addon_promo' => true,
6567 'group' => __('Form Features', 'mxchat'),
6568 'icon' => 'feedback',
6569 'description' => __('Collect user information through custom forms in chat', 'mxchat'),
6570 ),
6571 );
6572
6573 // Enhance add-on callbacks with installation status and addon info
6574 foreach ($addon_callbacks as $function => $data) {
6575 if (isset($addon_function_mapping[$function])) {
6576 $addon_info = $addon_function_mapping[$function];
6577
6578 $addon_callbacks[$function]['addon'] = $addon_info['addon'];
6579 $addon_callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6580 $addon_callbacks[$function]['installed'] = $addon_info['is_installed'];
6581
6582 // Set pro_only based on add-on configuration
6583 $addon_callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6584 } else {
6585 $addon_callbacks[$function]['addon'] = 'unknown';
6586 $addon_callbacks[$function]['addon_name'] = __('Unknown Add-on', 'mxchat');
6587 $addon_callbacks[$function]['installed'] = false;
6588 }
6589 }
6590
6591 // Initialize callbacks with core features
6592 $callbacks = $core_callbacks;
6593
6594 // Get callbacks from active add-ons
6595 $active_addon_callbacks = apply_filters('mxchat_available_callbacks', array());
6596
6597 // Add placeholder callbacks only for add-ons that aren't active
6598 if ($include_all) {
6599 foreach ($addon_callbacks as $function => $data) {
6600 // Skip placeholders for functions provided by active add-ons
6601 if (in_array($function, $addon_provided_functions)) {
6602 continue;
6603 }
6604
6605 // Skip excluded functions
6606 if (in_array($function, $excluded_functions)) {
6607 continue;
6608 }
6609
6610 // Add the placeholder
6611 $callbacks[$function] = $data;
6612 }
6613 }
6614
6615 // Add callbacks from active add-ons (will override placeholders)
6616 foreach ($active_addon_callbacks as $function => $data) {
6617 // Skip excluded functions
6618 if (in_array($function, $excluded_functions)) {
6619 continue;
6620 }
6621
6622 // Always include callbacks from add-ons
6623 $callbacks[$function] = $data;
6624
6625 // Ensure they have the proper add-on info
6626 if (isset($addon_function_mapping[$function])) {
6627 $addon_info = $addon_function_mapping[$function];
6628 $callbacks[$function]['addon'] = $addon_info['addon'];
6629 $callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6630 $callbacks[$function]['installed'] = $addon_info['is_installed'];
6631 $callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6632 }
6633 }
6634
6635 // Just before returning callbacks, sort them to prioritize free features
6636 if (!$grouped) {
6637 // Create temporary arrays for sorting
6638 $free_callbacks = array();
6639 $pro_callbacks = array();
6640
6641 // Split callbacks into free and pro
6642 foreach ($callbacks as $key => $data) {
6643 if (isset($data['pro_only']) && $data['pro_only']) {
6644 $pro_callbacks[$key] = $data;
6645 } else {
6646 $free_callbacks[$key] = $data;
6647 }
6648 }
6649
6650 // Merge with free callbacks first
6651 $callbacks = array_merge($free_callbacks, $pro_callbacks);
6652 }
6653
6654 // Return grouped structure if requested
6655 if ($grouped) {
6656 $grouped_callbacks = array();
6657 foreach ($callbacks as $key => $data) {
6658 $group_label = isset($data['group']) ? $data['group'] : __('Other Features', 'mxchat');
6659
6660 // Ensure we carry forward all the new fields in grouped mode
6661 $callback_data = array(
6662 'label' => $data['label'],
6663 'pro_only' => isset($data['pro_only']) ? $data['pro_only'] : false,
6664 'icon' => isset($data['icon']) ? $data['icon'] : 'admin-generic',
6665 'description' => isset($data['description']) ? $data['description'] : __('Custom action for your chatbot', 'mxchat'),
6666 'addon' => isset($data['addon']) ? $data['addon'] : false,
6667 'addon_name' => isset($data['addon_name']) ? $data['addon_name'] : '',
6668 'installed' => isset($data['installed']) ? $data['installed'] : true
6669 );
6670
6671 $grouped_callbacks[$group_label][$key] = $callback_data;
6672 }
6673
6674 // Sort within each group to prioritize free features
6675 foreach ($grouped_callbacks as $group => $items) {
6676 $free_items = array();
6677 $pro_items = array();
6678
6679 foreach ($items as $key => $data) {
6680 if (isset($data['pro_only']) && $data['pro_only']) {
6681 $pro_items[$key] = $data;
6682 } else {
6683 $free_items[$key] = $data;
6684 }
6685 }
6686
6687 $grouped_callbacks[$group] = array_merge($free_items, $pro_items);
6688 }
6689
6690 return $grouped_callbacks;
6691 }
6692
6693 return $callbacks;
6694 }
6695
6696 public function mxchat_page_init() {
6697 register_setting(
6698 'mxchat_option_group',
6699 'mxchat_options',
6700 array($this, 'mxchat_sanitize')
6701 );
6702
6703 register_setting(
6704 'mxchat_option_group',
6705 'mxchat_similarity_threshold',
6706 array(
6707 'type' => 'number',
6708 'sanitize_callback' => function($value) {
6709 $value = absint($value);
6710 return min(max($value, 20), 95);
6711 },
6712 'default' => 80,
6713 )
6714 );
6715
6716 // Chatbot Settings Section
6717 add_settings_section(
6718 'mxchat_chatbot_section',
6719 esc_html__('Chatbot Settings', 'mxchat'),
6720 null,
6721 'mxchat-chatbot'
6722 );
6723
6724 // API Keys Settings Section
6725 add_settings_section(
6726 'mxchat_api_keys_section',
6727 esc_html__('API Keys', 'mxchat'),
6728 array($this, 'mxchat_api_keys_section_callback'),
6729 'mxchat-api-keys'
6730 );
6731
6732 // OpenAI API Key
6733 add_settings_field(
6734 'api_key',
6735 esc_html__('OpenAI API Key', 'mxchat'),
6736 array($this, 'api_key_callback'),
6737 'mxchat-api-keys',
6738 'mxchat_api_keys_section'
6739 );
6740
6741 // X.AI API Key
6742 add_settings_field(
6743 'xai_api_key',
6744 esc_html__('X.AI API Key', 'mxchat'),
6745 array($this, 'xai_api_key_callback'),
6746 'mxchat-api-keys',
6747 'mxchat_api_keys_section'
6748 );
6749
6750 // Claude API Key
6751 add_settings_field(
6752 'claude_api_key',
6753 esc_html__('Claude API Key', 'mxchat'),
6754 array($this, 'claude_api_key_callback'),
6755 'mxchat-api-keys',
6756 'mxchat_api_keys_section'
6757 );
6758
6759 // DeepSeek API Key
6760 add_settings_field(
6761 'deepseek_api_key',
6762 esc_html__('DeepSeek API Key', 'mxchat'),
6763 array($this, 'deepseek_api_key_callback'),
6764 'mxchat-api-keys',
6765 'mxchat_api_keys_section'
6766 );
6767
6768 // Google Gemini API Key
6769 add_settings_field(
6770 'gemini_api_key',
6771 esc_html__('Google Gemini API Key', 'mxchat'),
6772 array($this, 'gemini_api_key_callback'),
6773 'mxchat-api-keys',
6774 'mxchat_api_keys_section'
6775 );
6776
6777 // Voyage AI API Key
6778 add_settings_field(
6779 'voyage_api_key',
6780 esc_html__('Voyage AI API Key', 'mxchat'),
6781 array($this, 'voyage_api_key_callback'),
6782 'mxchat-api-keys',
6783 'mxchat_api_keys_section'
6784 );
6785
6786 // OpenRouter API Key
6787 add_settings_field(
6788 'openrouter_api_key',
6789 esc_html__('OpenRouter API Key', 'mxchat'),
6790 array($this, 'openrouter_api_key_callback'),
6791 'mxchat-api-keys',
6792 'mxchat_api_keys_section'
6793 );
6794
6795 // Custom (OpenAI-compatible) Provider — for Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
6796 add_settings_field(
6797 'custom_provider',
6798 esc_html__('Custom Provider (OpenAI-compatible)', 'mxchat'),
6799 array($this, 'custom_provider_callback'),
6800 'mxchat-api-keys',
6801 'mxchat_api_keys_section'
6802 );
6803
6804 // Loops API Key
6805 add_settings_field(
6806 'loops_api_key',
6807 esc_html__('Loops API Key', 'mxchat'),
6808 array($this, 'mxchat_loops_api_key_callback'),
6809 'mxchat-api-keys',
6810 'mxchat_api_keys_section'
6811 );
6812
6813 // Brave Search API Key
6814 add_settings_field(
6815 'brave_api_key',
6816 __('Brave API Key', 'mxchat'),
6817 array($this, 'mxchat_brave_api_key_callback'),
6818 'mxchat-api-keys',
6819 'mxchat_api_keys_section'
6820 );
6821
6822 // Similarity Threshold Slider
6823 add_settings_field(
6824 'similarity_threshold', // Field ID
6825 esc_html__('Similarity Threshold', 'mxchat'), // Field title
6826 array($this, 'mxchat_similarity_threshold_callback'), // Callback function
6827 'mxchat-chatbot', // Page
6828 'mxchat_chatbot_section' // Section
6829 );
6830
6831 // RAG Sources Limit Slider
6832 add_settings_field(
6833 'rag_sources_limit', // Field ID
6834 esc_html__('RAG Sources Limit', 'mxchat'), // Field title
6835 array($this, 'mxchat_rag_sources_limit_callback'), // Callback function
6836 'mxchat-chatbot', // Page
6837 'mxchat_chatbot_section' // Section
6838 );
6839
6840 // RAG Chunks Limit Slider
6841 add_settings_field(
6842 'rag_chunks_limit', // Field ID
6843 esc_html__('RAG Chunks Limit', 'mxchat'), // Field title
6844 array($this, 'mxchat_rag_chunks_limit_callback'), // Callback function
6845 'mxchat-chatbot', // Page
6846 'mxchat_chatbot_section' // Section
6847 );
6848
6849 add_settings_field(
6850 'append_to_body',
6851 esc_html__('Auto-Display Chatbot', 'mxchat'),
6852 array($this, 'mxchat_append_to_body_callback'),
6853 'mxchat-chatbot',
6854 'mxchat_chatbot_section'
6855 );
6856
6857 add_settings_field(
6858 'contextual_awareness_toggle',
6859 esc_html__('Contextual Awareness', 'mxchat'),
6860 array($this, 'mxchat_contextual_awareness_callback'),
6861 'mxchat-chatbot',
6862 'mxchat_chatbot_section'
6863 );
6864
6865 add_settings_field(
6866 'citation_links_toggle',
6867 esc_html__('Citation Links', 'mxchat'),
6868 array($this, 'mxchat_citation_links_toggle_callback'),
6869 'mxchat-chatbot',
6870 'mxchat_chatbot_section'
6871 );
6872
6873 // Strip unapproved links (plan 58f8b4) — the enforcement half of the old
6874 // Citation Links conflation, now its own control.
6875 add_settings_field(
6876 'strip_unapproved_links_toggle',
6877 esc_html__('Strip Unapproved Links', 'mxchat'),
6878 array($this, 'mxchat_strip_unapproved_links_toggle_callback'),
6879 'mxchat-chatbot',
6880 'mxchat_chatbot_section'
6881 );
6882
6883 // Satisfaction rating toggle (plan-a5b006).
6884 add_settings_field(
6885 'satisfaction_rating_enabled',
6886 esc_html__('Satisfaction Rating Prompt', 'mxchat'),
6887 array($this, 'mxchat_satisfaction_rating_toggle_callback'),
6888 'mxchat-chatbot',
6889 'mxchat_chatbot_section'
6890 );
6891
6892 // Satisfaction rating customization (plan-141a12, plan-29caac):
6893 // the 5 customization fields are now inline-rendered inside
6894 // mxchat_satisfaction_rating_toggle_callback's sub-options wrapper.
6895
6896 add_settings_field(
6897 'enable_streaming_toggle',
6898 esc_html__('Enable Streaming', 'mxchat'),
6899 array($this, 'enable_streaming_toggle_callback'),
6900 'mxchat-chatbot',
6901 'mxchat_chatbot_section', // Same section as your working toggle
6902 array(
6903 'class' => 'mxchat-setting-row streaming-setting',
6904 'style' => 'display: none;' // Hidden by default, shown when OpenAI/Claude selected
6905 )
6906 );
6907
6908
6909 add_settings_field(
6910 'model',
6911 esc_html__('Chat Model', 'mxchat'),
6912 array($this, 'mxchat_model_callback'),
6913 'mxchat-chatbot',
6914 'mxchat_chatbot_section'
6915 );
6916
6917 add_settings_field(
6918 'embedding_model',
6919 esc_html__('Embedding Model', 'mxchat'),
6920 array($this, 'embedding_model_callback'),
6921 'mxchat-chatbot',
6922 'mxchat_chatbot_section'
6923 );
6924
6925 add_settings_field(
6926 'system_prompt_instructions',
6927 esc_html__('AI Instructions (Behavior)', 'mxchat'),
6928 array($this, 'system_prompt_instructions_callback'),
6929 'mxchat-chatbot',
6930 'mxchat_chatbot_section'
6931 );
6932
6933
6934 add_settings_field(
6935 'top_bar_title',
6936 esc_html__('Top Bar Title', 'mxchat'),
6937 array($this, 'mxchat_top_bar_title_callback'),
6938 'mxchat-chatbot',
6939 'mxchat_chatbot_section'
6940 );
6941
6942 add_settings_field(
6943 'ai_agent_text',
6944 esc_html__('AI Agent Text', 'mxchat'),
6945 array($this, 'mxchat_ai_agent_text_callback'),
6946 'mxchat-chatbot',
6947 'mxchat_chatbot_section'
6948 );
6949
6950 add_settings_field(
6951 'enable_email_block',
6952 esc_html__('Require Email To Chat', 'mxchat'),
6953 array($this, 'enable_email_block_callback'),
6954 'mxchat-chatbot',
6955 'mxchat_chatbot_section'
6956 );
6957
6958 add_settings_field(
6959 'email_blocker_header_content',
6960 esc_html__('Require Email Chat Content', 'mxchat'),
6961 array($this, 'email_blocker_header_content_callback'),
6962 'mxchat-chatbot',
6963 'mxchat_chatbot_section'
6964 );
6965
6966 add_settings_field(
6967 'email_blocker_button_text',
6968 esc_html__('Require Email Chat Button Text', 'mxchat'),
6969 [$this, 'email_blocker_button_text_callback'],
6970 'mxchat-chatbot',
6971 'mxchat_chatbot_section'
6972 );
6973
6974 add_settings_field(
6975 'enable_name_field',
6976 esc_html__('Require Name Field', 'mxchat'),
6977 array($this, 'enable_name_field_callback'),
6978 'mxchat-chatbot',
6979 'mxchat_chatbot_section'
6980 );
6981
6982 add_settings_field(
6983 'name_field_placeholder',
6984 esc_html__('Name Field Placeholder', 'mxchat'),
6985 array($this, 'name_field_placeholder_callback'),
6986 'mxchat-chatbot',
6987 'mxchat_chatbot_section'
6988 );
6989
6990 add_settings_field(
6991 'intro_message',
6992 esc_html__('Introductory Message', 'mxchat'),
6993 array($this, 'mxchat_intro_message_callback'),
6994 'mxchat-chatbot',
6995 'mxchat_chatbot_section'
6996 );
6997
6998 add_settings_field(
6999 'input_copy',
7000 esc_html__('Input Copy', 'mxchat'),
7001 array($this, 'mxchat_input_copy_callback'),
7002 'mxchat-chatbot',
7003 'mxchat_chatbot_section'
7004 );
7005
7006 add_settings_field(
7007 'pre_chat_message',
7008 esc_html__('Chat Teaser Pop-up', 'mxchat'),
7009 array($this, 'mxchat_pre_chat_message_callback'),
7010 'mxchat-chatbot',
7011 'mxchat_chatbot_section'
7012 );
7013
7014 add_settings_field(
7015 'privacy_toggle',
7016 esc_html__('Toggle Privacy Notice', 'mxchat'),
7017 array($this, 'mxchat_privacy_toggle_callback'),
7018 'mxchat-chatbot',
7019 'mxchat_chatbot_section'
7020 );
7021
7022 add_settings_field(
7023 'complianz_toggle',
7024 esc_html__('Enable Complianz', 'mxchat'),
7025 array($this, 'mxchat_complianz_toggle_callback'),
7026 'mxchat-chatbot',
7027 'mxchat_chatbot_section'
7028 );
7029
7030 add_settings_field(
7031 'link_target_toggle',
7032 esc_html__('Open Links in a New Tab', 'mxchat'),
7033 array($this, 'mxchat_link_target_toggle_callback'),
7034 'mxchat-chatbot',
7035 'mxchat_chatbot_section'
7036 );
7037
7038 add_settings_field(
7039 'chat_persistence_toggle',
7040 esc_html__('Enable Chat Persistence', 'mxchat'),
7041 array($this, 'mxchat_chat_persistence_toggle_callback'),
7042 'mxchat-chatbot',
7043 'mxchat_chatbot_section'
7044 );
7045
7046 add_settings_field(
7047 'print_button_enabled',
7048 esc_html__('Show Download Transcript Button', 'mxchat'),
7049 array($this, 'mxchat_print_button_toggle_callback'),
7050 'mxchat-chatbot',
7051 'mxchat_chatbot_section'
7052 );
7053
7054 add_settings_field(
7055 'reset_chat_enabled',
7056 esc_html__('Show Start-New-Chat Button', 'mxchat'),
7057 array($this, 'mxchat_reset_chat_toggle_callback'),
7058 'mxchat-chatbot',
7059 'mxchat_chatbot_section'
7060 );
7061
7062 add_settings_field(
7063 'reset_chat_label',
7064 esc_html__('Start-New-Chat Button Label', 'mxchat'),
7065 array($this, 'mxchat_reset_chat_label_callback'),
7066 'mxchat-chatbot',
7067 'mxchat_chatbot_section'
7068 );
7069
7070 add_settings_field(
7071 'popular_question_1',
7072 esc_html__('Quick Question 1', 'mxchat'),
7073 array($this, 'mxchat_popular_question_1_callback'),
7074 'mxchat-chatbot',
7075 'mxchat_chatbot_section'
7076 );
7077
7078 add_settings_field(
7079 'popular_question_2',
7080 esc_html__('Quick Question 2', 'mxchat'),
7081 array($this, 'mxchat_popular_question_2_callback'),
7082 'mxchat-chatbot',
7083 'mxchat_chatbot_section'
7084 );
7085
7086 add_settings_field(
7087 'popular_question_3',
7088 esc_html__('Quick Question 3', 'mxchat'),
7089 array($this, 'mxchat_popular_question_3_callback'),
7090 'mxchat-chatbot',
7091 'mxchat_chatbot_section'
7092 );
7093
7094 add_settings_field(
7095 'additional_popular_questions',
7096 esc_html__('Additional Quick Questions', 'mxchat'),
7097 array($this, 'mxchat_additional_popular_questions_callback'),
7098 'mxchat-chatbot',
7099 'mxchat_chatbot_section'
7100 );
7101
7102
7103 add_settings_field(
7104 'rate_limits',
7105 __('Rate Limits Settings', 'mxchat'),
7106 array($this, 'mxchat_rate_limits_callback'),
7107 'mxchat-chatbot',
7108 'mxchat_chatbot_section'
7109 );
7110
7111 // Loops Settings Section
7112 add_settings_section(
7113 'mxchat_loops_section',
7114 esc_html__('Loops Settings', 'mxchat'),
7115 null,
7116 'mxchat-embed'
7117 );
7118
7119 // Loops Settings Fields (API Key moved to API Keys tab)
7120 add_settings_field(
7121 'loops_mailing_list',
7122 esc_html__('Loops Mailing List', 'mxchat'),
7123 array($this, 'mxchat_loops_mailing_list_callback'),
7124 'mxchat-embed',
7125 'mxchat_loops_section'
7126 );
7127
7128 add_settings_field(
7129 'triggered_phrase_response',
7130 esc_html__('Triggered Phrase Response', 'mxchat'),
7131 array($this, 'mxchat_triggered_phrase_response_callback'),
7132 'mxchat-embed',
7133 'mxchat_loops_section'
7134 );
7135
7136 add_settings_field(
7137 'email_capture_response',
7138 esc_html__('Email Capture Response', 'mxchat'),
7139 array($this, 'mxchat_email_capture_response_callback'),
7140 'mxchat-embed',
7141 'mxchat_loops_section'
7142 );
7143
7144 // Brave Search Settings Fields
7145 add_settings_section(
7146 'mxchat_brave_section',
7147 __('Brave Search Settings', 'mxchat'),
7148 array($this, 'mxchat_brave_section_callback'),
7149 'mxchat-embed'
7150 );
7151
7152 // Brave API Key moved to API Keys tab
7153 add_settings_field(
7154 'brave_image_count',
7155 __('Number of Images to Return', 'mxchat'),
7156 array($this, 'mxchat_brave_image_count_callback'),
7157 'mxchat-embed',
7158 'mxchat_brave_section'
7159 );
7160
7161 add_settings_field(
7162 'brave_safe_search',
7163 __('Safe Search', 'mxchat'),
7164 array($this, 'mxchat_brave_safe_search_callback'),
7165 'mxchat-embed',
7166 'mxchat_brave_section'
7167 );
7168
7169 add_settings_field(
7170 'brave_news_count',
7171 __('Number of News Articles', 'mxchat'),
7172 array($this, 'mxchat_brave_news_count_callback'),
7173 'mxchat-embed',
7174 'mxchat_brave_section'
7175 );
7176
7177 add_settings_field(
7178 'brave_country',
7179 __('Country', 'mxchat'),
7180 array($this, 'mxchat_brave_country_callback'),
7181 'mxchat-embed',
7182 'mxchat_brave_section'
7183 );
7184
7185 add_settings_field(
7186 'brave_language',
7187 __('Language', 'mxchat'),
7188 array($this, 'mxchat_brave_language_callback'),
7189 'mxchat-embed',
7190 'mxchat_brave_section'
7191 );
7192
7193 // Chat with PDF Intent Settings Fields
7194 add_settings_section(
7195 'mxchat_pdf_intent_section',
7196 __('Toolbar Settings & Intents', 'mxchat'),
7197 array($this, 'mxchat_pdf_intent_section_callback'),
7198 'mxchat-embed'
7199 );
7200
7201 add_settings_field(
7202 'chat_toolbar_toggle',
7203 __('Show Chat Toolbar', 'mxchat'),
7204 array($this, 'mxchat_chat_toolbar_toggle_callback'),
7205 'mxchat-embed',
7206 'mxchat_pdf_intent_section'
7207 );
7208
7209 // PDF Upload Button Toggle
7210 add_settings_field(
7211 'show_pdf_upload_button',
7212 __('Show PDF Upload Button', 'mxchat'),
7213 array($this, 'mxchat_show_pdf_upload_button_callback'),
7214 'mxchat-embed',
7215 'mxchat_pdf_intent_section'
7216 );
7217
7218 // Word Upload Button Toggle
7219 add_settings_field(
7220 'show_word_upload_button',
7221 __('Show Word Upload Button', 'mxchat'),
7222 array($this, 'mxchat_show_word_upload_button_callback'),
7223 'mxchat-embed',
7224 'mxchat_pdf_intent_section'
7225 );
7226
7227 add_settings_field(
7228 'pdf_intent_trigger_text',
7229 __('Intent Trigger Text', 'mxchat'),
7230 array($this, 'mxchat_pdf_intent_trigger_text_callback'),
7231 'mxchat-embed',
7232 'mxchat_pdf_intent_section'
7233 );
7234
7235 add_settings_field(
7236 'pdf_intent_success_text',
7237 __('Success Text', 'mxchat'),
7238 array($this, 'mxchat_pdf_intent_success_text_callback'),
7239 'mxchat-embed',
7240 'mxchat_pdf_intent_section'
7241 );
7242
7243 add_settings_field(
7244 'pdf_intent_error_text',
7245 __('Error Text', 'mxchat'),
7246 array($this, 'mxchat_pdf_intent_error_text_callback'),
7247 'mxchat-embed',
7248 'mxchat_pdf_intent_section'
7249 );
7250
7251 // Add PDF Maximum Pages Field
7252 add_settings_field(
7253 'pdf_max_pages',
7254 __('Maximum Document Pages', 'mxchat'),
7255 array($this, 'mxchat_pdf_max_pages_callback'),
7256 'mxchat-embed',
7257 'mxchat_pdf_intent_section'
7258 );
7259
7260 // Live Agent Settings Fields
7261 add_settings_section(
7262 'mxchat_live_agent_section',
7263 __('Live Agent Settings', 'mxchat'),
7264 array($this, 'mxchat_live_agent_section_callback'),
7265 'mxchat-embed'
7266 );
7267
7268 // Live Agent Status Fields (add at top of live agent settings)
7269 add_settings_field(
7270 'live_agent_status',
7271 __('Live Agent Status', 'mxchat'),
7272 array($this, 'mxchat_live_agent_status_callback'),
7273 'mxchat-embed',
7274 'mxchat_live_agent_section'
7275 );
7276
7277 // Slack availability schedule (plans 8ccaa2 + 99d7a4). Each handoff channel
7278 // owns an independent schedule rendered under its own Integrations tab; this
7279 // one governs Slack only. Same callback as Telegram's, parameterized.
7280 add_settings_field(
7281 'live_agent_schedule_slack',
7282 __('Availability Schedule', 'mxchat'),
7283 array($this, 'mxchat_live_agent_schedule_callback'),
7284 'mxchat-embed',
7285 'mxchat_live_agent_section',
7286 array('channel' => 'slack')
7287 );
7288
7289 add_settings_field(
7290 'live_agent_notification_message',
7291 __('Notification Message', 'mxchat'),
7292 array($this, 'mxchat_live_agent_notification_message_callback'),
7293 'mxchat-embed',
7294 'mxchat_live_agent_section'
7295 );
7296
7297 add_settings_field(
7298 'live_agent_away_message',
7299 __('Away Message', 'mxchat'),
7300 array($this, 'mxchat_live_agent_away_message_callback'),
7301 'mxchat-embed',
7302 'mxchat_live_agent_section'
7303 );
7304
7305 add_settings_field(
7306 'live_agent_user_ids',
7307 __('Slack Agent User IDs', 'mxchat'),
7308 array($this, 'mxchat_live_agent_user_ids_callback'),
7309 'mxchat-embed',
7310 'mxchat_live_agent_section'
7311 );
7312
7313 // Shared handoff channel (plan 9f7756): route every handoff into one
7314 // pre-existing channel as threads instead of creating chat-* channels.
7315 add_settings_field(
7316 'live_agent_shared_channel',
7317 __('Shared Handoff Channel', 'mxchat'),
7318 array($this, 'mxchat_live_agent_shared_channel_callback'),
7319 'mxchat-embed',
7320 'mxchat_live_agent_section'
7321 );
7322
7323 // Auto-archive per-conversation chat- channels on !endchat (plan 7458a7).
7324 // Default OFF; never touches the shared handoff channel.
7325 add_settings_field(
7326 'live_agent_archive_on_end_toggle',
7327 __('Archive Channel When Chat Ends', 'mxchat'),
7328 array($this, 'mxchat_live_agent_archive_on_end_callback'),
7329 'mxchat-embed',
7330 'mxchat_live_agent_section'
7331 );
7332
7333 add_settings_field(
7334 'live_agent_webhook_url',
7335 __('Slack Webhook URL', 'mxchat'),
7336 array($this, 'mxchat_live_agent_webhook_url_callback'),
7337 'mxchat-embed',
7338 'mxchat_live_agent_section'
7339 );
7340
7341 add_settings_field(
7342 'live_agent_secret_key',
7343 __('Slack Secret Key', 'mxchat'),
7344 array($this, 'mxchat_live_agent_secret_key_callback'),
7345 'mxchat-embed',
7346 'mxchat_live_agent_section'
7347 );
7348
7349 // Live Agent Integration Fields
7350 add_settings_field(
7351 'live_agent_bot_token',
7352 __('Slack Bot OAuth Token', 'mxchat'),
7353 array($this, 'mxchat_live_agent_bot_token_callback'),
7354 'mxchat-embed',
7355 'mxchat_live_agent_section'
7356 );
7357
7358 // Telegram Integration Section
7359 add_settings_section(
7360 'mxchat_telegram_section',
7361 __('Telegram Settings', 'mxchat'),
7362 array($this, 'mxchat_telegram_section_callback'),
7363 'mxchat-embed'
7364 );
7365
7366 add_settings_field(
7367 'telegram_status',
7368 __('Live Agent Status', 'mxchat'),
7369 array($this, 'mxchat_telegram_status_callback'),
7370 'mxchat-embed',
7371 'mxchat_telegram_section'
7372 );
7373
7374 // Telegram availability schedule (plan 99d7a4) — independent of Slack's,
7375 // rendered right under the Telegram status toggle it extends.
7376 add_settings_field(
7377 'live_agent_schedule_telegram',
7378 __('Availability Schedule', 'mxchat'),
7379 array($this, 'mxchat_live_agent_schedule_callback'),
7380 'mxchat-embed',
7381 'mxchat_telegram_section',
7382 array('channel' => 'telegram')
7383 );
7384
7385 add_settings_field(
7386 'telegram_notification_message',
7387 __('Notification Message', 'mxchat'),
7388 array($this, 'mxchat_telegram_notification_message_callback'),
7389 'mxchat-embed',
7390 'mxchat_telegram_section'
7391 );
7392
7393 add_settings_field(
7394 'telegram_away_message',
7395 __('Away Message', 'mxchat'),
7396 array($this, 'mxchat_telegram_away_message_callback'),
7397 'mxchat-embed',
7398 'mxchat_telegram_section'
7399 );
7400
7401 add_settings_field(
7402 'telegram_bot_token',
7403 __('Telegram Bot Token', 'mxchat'),
7404 array($this, 'mxchat_telegram_bot_token_callback'),
7405 'mxchat-embed',
7406 'mxchat_telegram_section'
7407 );
7408
7409 add_settings_field(
7410 'telegram_group_id',
7411 __('Telegram Group ID', 'mxchat'),
7412 array($this, 'mxchat_telegram_group_id_callback'),
7413 'mxchat-embed',
7414 'mxchat_telegram_section'
7415 );
7416
7417 add_settings_field(
7418 'telegram_webhook_secret',
7419 __('Webhook Secret Token', 'mxchat'),
7420 array($this, 'mxchat_telegram_webhook_secret_callback'),
7421 'mxchat-embed',
7422 'mxchat_telegram_section'
7423 );
7424
7425 // Webhook Handoff Section (plan d88e22) — the third live-agent destination.
7426 // Outbound-only: handoffs POST to the owner's URL; the chat stays in AI mode.
7427 add_settings_section(
7428 'mxchat_webhook_handoff_section',
7429 __('Webhook Handoff Settings', 'mxchat'),
7430 array($this, 'mxchat_webhook_handoff_section_callback'),
7431 'mxchat-embed'
7432 );
7433
7434 add_settings_field(
7435 'webhook_handoff_status',
7436 __('Live Agent Status', 'mxchat'),
7437 array($this, 'mxchat_webhook_handoff_status_callback'),
7438 'mxchat-embed',
7439 'mxchat_webhook_handoff_section'
7440 );
7441
7442 // Webhook availability schedule — independent of Slack's and Telegram's,
7443 // same parameterized editor (plans 8ccaa2 + 99d7a4 + d88e22).
7444 add_settings_field(
7445 'live_agent_schedule_webhook',
7446 __('Availability Schedule', 'mxchat'),
7447 array($this, 'mxchat_live_agent_schedule_callback'),
7448 'mxchat-embed',
7449 'mxchat_webhook_handoff_section',
7450 array('channel' => 'webhook')
7451 );
7452
7453 add_settings_field(
7454 'webhook_handoff_notification_message',
7455 __('Notification Message', 'mxchat'),
7456 array($this, 'mxchat_webhook_handoff_notification_message_callback'),
7457 'mxchat-embed',
7458 'mxchat_webhook_handoff_section'
7459 );
7460
7461 add_settings_field(
7462 'webhook_handoff_away_message',
7463 __('Away Message', 'mxchat'),
7464 array($this, 'mxchat_webhook_handoff_away_message_callback'),
7465 'mxchat-embed',
7466 'mxchat_webhook_handoff_section'
7467 );
7468
7469 add_settings_field(
7470 'webhook_handoff_url',
7471 __('Webhook URL', 'mxchat'),
7472 array($this, 'mxchat_webhook_handoff_url_callback'),
7473 'mxchat-embed',
7474 'mxchat_webhook_handoff_section'
7475 );
7476
7477 add_settings_field(
7478 'webhook_handoff_secret',
7479 __('Signing Secret', 'mxchat'),
7480 array($this, 'mxchat_webhook_handoff_secret_callback'),
7481 'mxchat-embed',
7482 'mxchat_webhook_handoff_section'
7483 );
7484
7485 // General Settings Section
7486 add_settings_section(
7487 'mxchat_general_section',
7488 esc_html__('YouTube Tutorials', 'mxchat'),
7489 null,
7490 'mxchat-general'
7491 );
7492 }
7493
7494 public function mxchat_prompts_page_init() {
7495 register_setting(
7496 'mxchat_prompts_options',
7497 'mxchat_prompts_options',
7498 array(
7499 'type' => 'array',
7500 'description' => __('MXChat Knowledge Base Settings', 'mxchat'),
7501 'default' => array(
7502 'mxchat_auto_sync_posts' => 0,
7503 'mxchat_auto_sync_pages' => 0,
7504 'mxchat_use_pinecone' => 0,
7505 'mxchat_pinecone_api_key' => '',
7506 'mxchat_pinecone_environment' => '',
7507 'mxchat_pinecone_index' => '',
7508 'mxchat_pinecone_host' => '',
7509 ),
7510 'sanitize_callback' => array($this, 'sanitize_prompts_options'),
7511 )
7512 );
7513
7514 add_action('admin_notices', array($this, 'sync_settings_notice'));
7515 }
7516
7517 public function mxchat_transcripts_page_init() {
7518 register_setting(
7519 'mxchat_transcripts_options',
7520 'mxchat_transcripts_options',
7521 array(
7522 'type' => 'array',
7523 'description' => __('MXChat Transcripts Notification Settings', 'mxchat'),
7524 'default' => array(
7525 'mxchat_enable_notifications' => 0,
7526 'mxchat_notification_email' => get_option('admin_email'),
7527 'mxchat_auto_delete_transcripts' => 'never',
7528 ),
7529 'sanitize_callback' => array($this, 'sanitize_transcripts_options'),
7530 )
7531 );
7532
7533 add_settings_section(
7534 'mxchat_transcripts_notification_section',
7535 esc_html__('Chat Notification Settings', 'mxchat'),
7536 array($this, 'mxchat_transcripts_notification_section_callback'),
7537 'mxchat-transcripts'
7538 );
7539
7540 add_settings_field(
7541 'mxchat_enable_notifications',
7542 esc_html__('Enable Chat Notifications', 'mxchat'),
7543 array($this, 'mxchat_enable_notifications_callback'),
7544 'mxchat-transcripts',
7545 'mxchat_transcripts_notification_section'
7546 );
7547
7548 add_settings_field(
7549 'mxchat_notification_email',
7550 esc_html__('Notification Email Address', 'mxchat'),
7551 array($this, 'mxchat_notification_email_callback'),
7552 'mxchat-transcripts',
7553 'mxchat_transcripts_notification_section'
7554 );
7555
7556 add_settings_field(
7557 'mxchat_auto_delete_transcripts',
7558 esc_html__('Auto-Delete Old Transcripts', 'mxchat'),
7559 array($this, 'mxchat_auto_delete_transcripts_callback'),
7560 'mxchat-transcripts',
7561 'mxchat_transcripts_notification_section'
7562 );
7563
7564 add_settings_field(
7565 'mxchat_retention_days',
7566 esc_html__('Custom Retention (Days)', 'mxchat'),
7567 array($this, 'mxchat_retention_days_callback'),
7568 'mxchat-transcripts',
7569 'mxchat_transcripts_notification_section'
7570 );
7571
7572 add_settings_field(
7573 'mxchat_auto_email_transcript',
7574 esc_html__('Auto-Email Full Transcript', 'mxchat'),
7575 array($this, 'mxchat_auto_email_transcript_callback'),
7576 'mxchat-transcripts',
7577 'mxchat_transcripts_notification_section'
7578 );
7579 }
7580
7581
7582 /**
7583 * Sanitize all prompts options
7584 *
7585 * @param array $input The unsanitized options array
7586 * @return array The sanitized options array
7587 */
7588 public function sanitize_prompts_options($input) {
7589 // Log the incoming input.
7590 //error_log('Sanitizing inputs: ' . print_r($input, true));
7591
7592 $sanitized = array();
7593
7594 // Boolean options
7595 $sanitized['mxchat_auto_sync_posts'] = isset($input['mxchat_auto_sync_posts']) ? 1 : 0;
7596 $sanitized['mxchat_auto_sync_pages'] = isset($input['mxchat_auto_sync_pages']) ? 1 : 0;
7597 $sanitized['mxchat_use_pinecone'] = !empty($input['mxchat_use_pinecone']) ? 1 : 0;
7598
7599 // API Key: if less than 32 characters, flag as invalid.
7600 $api_key = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
7601 if (!empty($api_key) && strlen($api_key) < 32) {
7602 add_settings_error(
7603 'mxchat_prompts_options',
7604 'invalid_api_key',
7605 __('The Pinecone API key appears to be invalid. Please check your API key.', 'mxchat')
7606 );
7607 $existing_options = get_option('mxchat_prompts_options', array());
7608 $sanitized['mxchat_pinecone_api_key'] = $existing_options['mxchat_pinecone_api_key'] ?? '';
7609 } else {
7610 $sanitized['mxchat_pinecone_api_key'] = $api_key;
7611 }
7612
7613 // Environment and Index Name
7614 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
7615 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
7616
7617 // Host: Remove protocol and validate format.
7618 $host = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
7619 $host = preg_replace('#^https?://#', '', $host);
7620 //error_log('Host after removing protocol: ' . $host);
7621 if (!empty($host)) {
7622 if (!preg_match('/^[\w-]+\.svc\.[\w-]+\.pinecone\.io$/', $host)) {
7623 add_settings_error(
7624 'mxchat_prompts_options',
7625 'invalid_host',
7626 __('The Pinecone host appears to be invalid. It should look like "mxchat-vectors-zrmsquq.svc.aped-4627-b74a.pinecone.io"', 'mxchat')
7627 );
7628 $existing_options = get_option('mxchat_prompts_options', array());
7629 $sanitized['mxchat_pinecone_host'] = $existing_options['mxchat_pinecone_host'] ?? '';
7630 } else {
7631 $sanitized['mxchat_pinecone_host'] = $host;
7632 }
7633 } else {
7634 $sanitized['mxchat_pinecone_host'] = '';
7635 }
7636
7637 //error_log('Final sanitized array: ' . print_r($sanitized, true));
7638
7639 return $sanitized;
7640 }
7641
7642 public function sync_settings_notice() {
7643 // Only show notice on our plugin page
7644 if (!isset($_GET['page']) || $_GET['page'] !== 'mxchat-prompts') {
7645 return;
7646 }
7647
7648 // Check if settings were updated
7649 if (isset($_GET['settings-updated'])) {
7650
7651 ?>
7652 <div class="notice notice-success is-dismissible">
7653 <p><?php esc_html_e('Sync settings updated successfully.', 'mxchat'); ?></p>
7654 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php esc_html_e('Dismiss this notice.', 'mxchat'); ?></span></button>
7655 </div>
7656 <?php
7657
7658 }
7659 }
7660 // Add this sanitization function to your class
7661 public function sanitize_sync_setting($input) {
7662 return (bool)$input ? __('1', 'mxchat') : __('', 'mxchat');
7663 }
7664
7665 public function mxchat_rate_limits_callback() {
7666 $all_options = get_option('mxchat_options', []);
7667
7668 // Define available rate limits
7669 $rate_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
7670
7671 // Define available timeframes
7672 $timeframes = array(
7673 'hourly' => __('Per Hour', 'mxchat'),
7674 'daily' => __('Per Day', 'mxchat'),
7675 'weekly' => __('Per Week', 'mxchat'),
7676 'monthly' => __('Per Month', 'mxchat')
7677 );
7678
7679 // Get all roles plus a "logged_out" pseudo-role
7680 $roles = wp_roles()->get_names();
7681 $roles['logged_out'] = __('Logged Out Users', 'mxchat');
7682
7683 // Start the wrapper
7684 echo '<div class="pro-feature-wrapper active">';
7685 echo '<div class="mxchat-rate-limits-container">';
7686
7687 echo '<p class="description" style="margin-bottom: 20px;">' .
7688 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') .
7689 '</p>';
7690
7691 // Add markdown link documentation
7692 echo '<div class="notice notice-info inline" style="margin-bottom: 20px; padding: 10px;">';
7693 echo '<p><strong>' . esc_html__('Markdown Links Supported:', 'mxchat') . '</strong></p>';
7694 echo '<p>' . esc_html__('You can include clickable links in your custom messages using markdown syntax:', 'mxchat') . '</p>';
7695 echo '<ul style="margin-left: 20px;">';
7696 echo '<li><code>[Link text](https://example.com)</code> - Creates a clickable link</li>';
7697 echo '<li><code>[Visit our pricing](https://example.com/pricing)</code> - Link with custom text</li>';
7698 echo '<li><code>Plain URLs like https://example.com will also become clickable</code></li>';
7699 echo '</ul>';
7700 echo '</div>';
7701
7702 // Output the controls for each role
7703 foreach ($roles as $role_id => $role_name) {
7704 // Get saved options or defaults
7705 $default_limit = ($role_id === 'logged_out') ? '10' : '100';
7706 $default_timeframe = 'daily';
7707 $default_message = __('Rate limit exceeded. Please try again later.', 'mxchat');
7708
7709 $selected_limit = isset($all_options['rate_limits'][$role_id]['limit'])
7710 ? $all_options['rate_limits'][$role_id]['limit']
7711 : $default_limit;
7712
7713 $selected_timeframe = isset($all_options['rate_limits'][$role_id]['timeframe'])
7714 ? $all_options['rate_limits'][$role_id]['timeframe']
7715 : $default_timeframe;
7716
7717 $custom_message = isset($all_options['rate_limits'][$role_id]['message'])
7718 ? $all_options['rate_limits'][$role_id]['message']
7719 : $default_message;
7720
7721 // Output the row
7722 echo '<div class="mxchat-rate-limit-row mxchat-autosave-section">';
7723
7724 // Role label
7725 echo '<div class="mxchat-rate-limit-role">' . esc_html($role_name) . '</div>';
7726
7727 // Controls section
7728 echo '<div class="mxchat-rate-limit-controls-wrapper">';
7729
7730 // Rate limit and timeframe controls
7731 echo '<div class="mxchat-rate-limit-controls">';
7732
7733 // Limit dropdown
7734 echo '<div>';
7735 echo '<label for="rate_limits_' . esc_attr($role_id) . '_limit">' . esc_html__('Limit:', 'mxchat') . '</label>';
7736 echo '<select
7737 id="rate_limits_' . esc_attr($role_id) . '_limit"
7738 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][limit]"
7739 class="mxchat-autosave-field">';
7740 foreach ($rate_limits as $limit) {
7741 echo '<option value="' . esc_attr($limit) . '" ' . selected($selected_limit, $limit, false) . '>' . esc_html($limit) . '</option>';
7742 }
7743 echo '</select>';
7744 echo '</div>';
7745
7746 // Timeframe dropdown
7747 echo '<div>';
7748 echo '<label for="rate_limits_' . esc_attr($role_id) . '_timeframe">' . esc_html__('Timeframe:', 'mxchat') . '</label>';
7749 echo '<select
7750 id="rate_limits_' . esc_attr($role_id) . '_timeframe"
7751 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][timeframe]"
7752 class="mxchat-autosave-field">';
7753 foreach ($timeframes as $value => $label) {
7754 echo '<option value="' . esc_attr($value) . '" ' . selected($selected_timeframe, $value, false) . '>' . esc_html($label) . '</option>';
7755 }
7756 echo '</select>';
7757 echo '</div>';
7758
7759 echo '</div>'; // End controls
7760
7761 // Custom message textarea
7762 echo '<div class="mxchat-rate-limit-message">';
7763 echo '<label for="rate_limits_' . esc_attr($role_id) . '_message">' . esc_html__('Custom Message:', 'mxchat') . '</label>';
7764 echo '<textarea
7765 id="rate_limits_' . esc_attr($role_id) . '_message"
7766 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][message]"
7767 class="mxchat-autosave-field"
7768 placeholder="' . esc_attr__('Enter custom message when rate limit is exceeded', 'mxchat') . '">' .
7769 esc_textarea($custom_message) .
7770 '</textarea>';
7771 echo '<p class="description">' .
7772 esc_html__('Example: Rate limit reached! [Visit our pricing page](https://example.com/pricing) to upgrade.', 'mxchat') .
7773 '</p>';
7774 echo '</div>'; // End message
7775
7776 echo '</div>'; // End controls wrapper
7777
7778 echo '</div>'; // End row
7779 }
7780
7781 echo '</div>'; // End container
7782
7783 echo '</div>'; // End pro-feature-wrapper
7784 }
7785
7786 private function mxchat_add_option_field($id, $title, $callback = '') {
7787 add_settings_field(
7788 $id,
7789 __($title, 'mxchat'),
7790 $callback ? array($this, $callback) : array($this, $id . '_callback'),
7791 'mxchat-max',
7792 'mxchat_setting_section_id',
7793 $id === 'model' ? ['label_for' => 'model'] : []
7794 );
7795 }
7796
7797 // API Keys Section Callback
7798 public function mxchat_api_keys_section_callback() {
7799 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>';
7800 }
7801
7802 // OpenAI API Key
7803 public function api_key_callback() {
7804 $apiKey = isset($this->options['api_key']) ? esc_attr($this->options['api_key']) : '';
7805 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7806
7807 echo '<div class="api-key-wrapper">';
7808 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 . '" />';
7809 echo '<button type="button" id="toggleApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7810 echo '<p class="description">' . esc_html__('Required for OpenAI GPT models and OpenAI embeddings. Get your API key from OpenAI Platform.', 'mxchat') . '</p>';
7811 echo $this->mxchat_provider_key_test_button('openai', 'api_key');
7812 echo '</div>';
7813 }
7814
7815 // X.AI API Key
7816 public function xai_api_key_callback() {
7817 $xaiApiKey = isset($this->options['xai_api_key']) ? esc_attr($this->options['xai_api_key']) : '';
7818 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7819
7820 echo '<div class="api-key-wrapper">';
7821 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 . '" />';
7822 echo '<button type="button" id="toggleXaiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7823 echo '<p class="description">' . esc_html__('Required for X.AI Grok models. Get your API key from X.AI Console.', 'mxchat') . '</p>';
7824 echo $this->mxchat_provider_key_test_button('xai', 'xai_api_key');
7825 echo '</div>';
7826 }
7827 // Claude API Key
7828 public function claude_api_key_callback() {
7829 $claudeApiKey = isset($this->options['claude_api_key']) ? esc_attr($this->options['claude_api_key']) : '';
7830 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7831
7832 echo '<div class="api-key-wrapper">';
7833 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 . '" />';
7834 echo '<button type="button" id="toggleClaudeApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7835 echo '<p class="description">' . esc_html__('Required for Anthropic Claude models. Get your API key from Anthropic Console.', 'mxchat') . '</p>';
7836 echo $this->mxchat_provider_key_test_button('claude', 'claude_api_key');
7837 echo '</div>';
7838 }
7839
7840 // DeepSeek API Key
7841 public function deepseek_api_key_callback() {
7842 $apiKey = isset($this->options['deepseek_api_key']) ? esc_attr($this->options['deepseek_api_key']) : '';
7843 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7844
7845 echo '<div class="api-key-wrapper">';
7846 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 . '" />';
7847 echo '<button type="button" id="toggleDeepSeekApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7848 echo '<p class="description">' . esc_html__('Required for DeepSeek models. Get your API key from DeepSeek Platform.', 'mxchat') . '</p>';
7849 echo $this->mxchat_provider_key_test_button('deepseek', 'deepseek_api_key');
7850 echo '</div>';
7851 }
7852
7853 // Gemini API Key
7854 public function gemini_api_key_callback() {
7855 $geminiApiKey = isset($this->options['gemini_api_key']) ? esc_attr($this->options['gemini_api_key']) : '';
7856 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7857
7858 echo '<div class="api-key-wrapper">';
7859 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 . '" />';
7860 echo '<button type="button" id="toggleGeminiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7861 echo '<p class="description">' . esc_html__('Required for Google Gemini models and embeddings. Get your API key from Google AI Studio.', 'mxchat') . '</p>';
7862 echo $this->mxchat_provider_key_test_button('gemini', 'gemini_api_key');
7863 echo '</div>';
7864 }
7865
7866
7867 // OpenRouter API Key
7868 public function openrouter_api_key_callback() {
7869 $openrouterApiKey = isset($this->options['openrouter_api_key']) ? esc_attr($this->options['openrouter_api_key']) : '';
7870 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7871
7872 echo '<div class="api-key-wrapper">';
7873 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 . '" />';
7874 echo '<button type="button" id="toggleOpenRouterApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7875 echo '<p class="description">' . esc_html__('Required for OpenRouter models. Get your API key from OpenRouter.ai', 'mxchat') . '</p>';
7876 echo $this->mxchat_provider_key_test_button('openrouter', 'openrouter_api_key');
7877 echo '</div>';
7878 }
7879
7880 /**
7881 * Renders a "Test key" button + result target next to a built-in provider key
7882 * field, and emits the shared delegated click handler ONCE (static guard). The
7883 * button carries data-target = the key field id so the owner can test the value
7884 * they just typed (test-before-save); the AJAX handler falls back to the saved
7885 * key when the field is empty. Styled with the WP `button` class to match the
7886 * adjacent Custom-provider "Test Connection" button on this same page.
7887 * plan-mxchat-20260623-c41f74.
7888 */
7889 public function mxchat_provider_key_test_button($provider, $target_id) {
7890 static $script_emitted = false;
7891 $nonce = wp_create_nonce('mxchat_test_provider_key');
7892 $html = '<div class="mxchat-key-test" style="margin-top:8px;">'
7893 . '<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>'
7894 . '<span class="mxchat-test-provider-key-result" style="margin-left:10px;font-size:13px;vertical-align:middle;"></span>'
7895 . '</div>';
7896 if (!$script_emitted) {
7897 $script_emitted = true;
7898 $html .= $this->mxchat_provider_key_test_script();
7899 }
7900 return $html;
7901 }
7902
7903 /**
7904 * One-time delegated click handler shared by every .mxchat-test-provider-key
7905 * button. Posts the typed key value + provider to mxchat_test_provider_key and
7906 * renders the success/error message inline. Mirrors the Custom-provider test.
7907 */
7908 private function mxchat_provider_key_test_script() {
7909 $t_testing = esc_js(__('Testing...', 'mxchat'));
7910 $t_valid = esc_js(__('Key is valid.', 'mxchat'));
7911 $t_failed = esc_js(__('Failed', 'mxchat'));
7912 $t_req = esc_js(__('Request failed', 'mxchat'));
7913 return '<script>(function(){'
7914 . 'if (window.__mxchatProviderKeyTestWired) { return; }'
7915 . 'window.__mxchatProviderKeyTestWired = true;'
7916 . 'document.addEventListener("click", function(e){'
7917 . 'var btn = e.target && e.target.closest ? e.target.closest(".mxchat-test-provider-key") : null;'
7918 . 'if (!btn) { return; }'
7919 . 'e.preventDefault();'
7920 . 'var field = btn.getAttribute("data-target") ? document.getElementById(btn.getAttribute("data-target")) : null;'
7921 . 'var out = btn.parentNode ? btn.parentNode.querySelector(".mxchat-test-provider-key-result") : null;'
7922 . 'if (out) { out.textContent = "' . $t_testing . '"; out.style.color = "#646970"; }'
7923 . 'btn.disabled = true;'
7924 . 'var fd = new FormData();'
7925 . 'fd.append("action", "mxchat_test_provider_key");'
7926 . 'fd.append("_wpnonce", btn.getAttribute("data-nonce"));'
7927 . 'fd.append("provider", btn.getAttribute("data-provider") || "");'
7928 . 'fd.append("key", field ? field.value : "");'
7929 . 'fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })'
7930 . '.then(function(r){ return r.json(); })'
7931 . '.then(function(j){'
7932 . 'if (out) {'
7933 . 'if (j && j.success) { out.textContent = "✓ " + ((j.data && j.data.message) ? j.data.message : "' . $t_valid . '"); out.style.color = "#00a32a"; }'
7934 . 'else { out.textContent = "⚠ " + ((j && j.data && j.data.message) ? j.data.message : "' . $t_failed . '"); out.style.color = "#d63638"; }'
7935 . '}'
7936 . 'btn.disabled = false;'
7937 . '})'
7938 . '.catch(function(){ if (out) { out.textContent = "⚠ ' . $t_req . '"; out.style.color = "#d63638"; } btn.disabled = false; });'
7939 . '});'
7940 . '})();</script>';
7941 }
7942
7943 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
7944 public function custom_provider_callback() {
7945 $base_url = isset($this->options['custom_provider_base_url']) ? esc_attr($this->options['custom_provider_base_url']) : '';
7946 $api_key = isset($this->options['custom_provider_api_key']) ? esc_attr($this->options['custom_provider_api_key']) : '';
7947 $model_name = isset($this->options['custom_provider_model']) ? esc_attr($this->options['custom_provider_model']) : '';
7948 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? esc_attr($this->options['custom_provider_auth_scheme']) : 'bearer';
7949 $api_version = isset($this->options['custom_provider_api_version']) ? esc_attr($this->options['custom_provider_api_version']) : '';
7950 $use_embed = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
7951 $use_images = !empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on';
7952 $embed_model = isset($this->options['custom_provider_embedding_model']) ? esc_attr($this->options['custom_provider_embedding_model']) : '';
7953 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7954 $test_nonce = wp_create_nonce('mxchat_test_custom_provider');
7955
7956 echo '<style>
7957 .mxchat-cp { max-width: 680px; }
7958 .mxchat-cp .mxchat-cp-intro { margin: 0 0 16px; color: #50575e; font-size: 13px; line-height: 1.5; }
7959 .mxchat-cp .mxchat-cp-row { display: block; margin: 0 0 18px; }
7960 .mxchat-cp .mxchat-cp-row > label { display: block; font-weight: 600; margin: 0 0 6px; color: #1d2327; font-size: 13px; }
7961 .mxchat-cp .mxchat-cp-row > input[type="text"],
7962 .mxchat-cp .mxchat-cp-row > input[type="password"],
7963 .mxchat-cp .mxchat-cp-row > select { display: block; width: 100%; max-width: 480px; margin: 0; }
7964 .mxchat-cp .mxchat-cp-row > .description { display: block; margin: 6px 0 0; color: #646970; font-size: 12px; line-height: 1.5; max-width: 480px; }
7965 .mxchat-cp .mxchat-cp-test { margin-top: 4px; padding-top: 14px; border-top: 1px solid #e5e7eb; }
7966 .mxchat-cp .mxchat-cp-test .mxchat-cp-test-status { display: inline-block; margin-left: 10px; vertical-align: middle; font-size: 13px; }
7967 .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; }
7968 .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; }
7969 .mxchat-cp .mxchat-cp-azure ol { margin: 0; padding: 0 0 0 18px; color: #50575e; font-size: 12px; line-height: 1.7; }
7970 .mxchat-cp .mxchat-cp-azure code { background: #eceefb; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
7971 </style>';
7972
7973 echo '<div class="api-key-wrapper mxchat-cp">';
7974 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>';
7975
7976 // Azure OpenAI quick start — consolidates the 4-field Azure recipe in one scannable callout
7977 // so admins can configure Azure without piecing it together from each field's hint.
7978 echo '<div class="mxchat-cp-azure">';
7979 echo '<span class="mxchat-cp-azure-title">' . esc_html__('Azure OpenAI quick start', 'mxchat') . '</span>';
7980 echo '<ol>';
7981 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>';
7982 echo '<li>' . wp_kses(__('<strong>API Key</strong> → your Azure OpenAI key (required)', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7983 echo '<li>' . wp_kses(__('<strong>Auth Scheme</strong> → <code>api-key header (Azure OpenAI)</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7984 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>';
7985 echo '</ol>';
7986 echo '</div>';
7987
7988 echo '<div class="mxchat-cp-row">';
7989 echo '<label for="custom_provider_base_url">' . esc_html__('Base URL', 'mxchat') . '</label>';
7990 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 . '" />';
7991 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>';
7992 echo '</div>';
7993
7994 echo '<div class="mxchat-cp-row">';
7995 echo '<label for="custom_provider_api_key">' . esc_html__('API Key (optional)', 'mxchat') . '</label>';
7996 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 . '" />';
7997 echo '<p class="description">' . esc_html__('Leave empty for unauthenticated local servers. Required for Azure / vLLM / hosted endpoints.', 'mxchat') . '</p>';
7998 echo '</div>';
7999
8000 echo '<div class="mxchat-cp-row">';
8001 echo '<label for="custom_provider_model">' . esc_html__('Model Name', 'mxchat') . '</label>';
8002 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 . '" />';
8003 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>';
8004 echo '</div>';
8005
8006 echo '<div class="mxchat-cp-row">';
8007 echo '<label for="custom_provider_auth_scheme">' . esc_html__('Auth Scheme', 'mxchat') . '</label>';
8008 echo '<select id="custom_provider_auth_scheme" name="custom_provider_auth_scheme" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
8009 echo '<option value="bearer"' . selected($auth_scheme, 'bearer', false) . '>' . esc_html__('Authorization: Bearer (OpenAI / Ollama / vLLM / LM Studio)', 'mxchat') . '</option>';
8010 echo '<option value="api-key"' . selected($auth_scheme, 'api-key', false) . '>' . esc_html__('api-key header (Azure OpenAI)', 'mxchat') . '</option>';
8011 echo '</select>';
8012 echo '<p class="description">' . esc_html__('Most OpenAI-compatible servers use Bearer. Azure OpenAI uses the api-key header.', 'mxchat') . '</p>';
8013 echo '</div>';
8014
8015 echo '<div class="mxchat-cp-row">';
8016 echo '<label for="custom_provider_api_version">' . esc_html__('API Version (Azure only)', 'mxchat') . '</label>';
8017 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 . '" />';
8018 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>';
8019 echo '</div>';
8020
8021 // Extended-use checkboxes — opt-in routing of other dispatcher paths through the custom provider.
8022 echo '<div class="mxchat-cp-row">';
8023 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>';
8024 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>';
8025 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>';
8026 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>';
8027 echo '</div>';
8028
8029 echo '<div class="mxchat-cp-row">';
8030 echo '<label for="custom_provider_embedding_model">' . esc_html__('Custom Embedding Model', 'mxchat') . '</label>';
8031 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 . '" />';
8032 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>';
8033 echo '</div>';
8034
8035 echo '<div class="mxchat-cp-test">';
8036 echo '<button type="button" class="button" id="mxchat-test-custom-provider" data-nonce="' . $test_nonce . '">' . esc_html__('Test Connection', 'mxchat') . '</button>';
8037 echo '<span id="mxchat-test-custom-provider-result" class="mxchat-cp-test-status"></span>';
8038 echo '</div>';
8039
8040 echo '<script>(function(){
8041 var btn = document.getElementById("mxchat-test-custom-provider");
8042 if (!btn || btn._wired) { return; } btn._wired = true;
8043 btn.addEventListener("click", function(){
8044 var out = document.getElementById("mxchat-test-custom-provider-result");
8045 out.textContent = "' . esc_js(__('Testing...', 'mxchat')) . '";
8046 out.style.color = "#646970";
8047 var fd = new FormData();
8048 fd.append("action", "mxchat_test_custom_provider");
8049 fd.append("_wpnonce", btn.getAttribute("data-nonce"));
8050 fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })
8051 .then(function(r){ return r.json(); })
8052 .then(function(j){
8053 if (j && j.success) {
8054 out.textContent = "✓ " + (j.data && j.data.message ? j.data.message : "' . esc_js(__('OK', 'mxchat')) . '");
8055 out.style.color = "#00a32a";
8056 } else {
8057 out.textContent = "⚠ " + (j && j.data && j.data.message ? j.data.message : "' . esc_js(__('Failed', 'mxchat')) . '");
8058 out.style.color = "#d63638";
8059 }
8060 })
8061 .catch(function(){
8062 out.textContent = "⚠ ' . esc_js(__('Request failed', 'mxchat')) . '";
8063 out.style.color = "#d63638";
8064 });
8065 });
8066 })();</script>';
8067
8068 echo '</div>';
8069 }
8070
8071 // Voyage API Key
8072 public function voyage_api_key_callback() {
8073 $apiKey = isset($this->options['voyage_api_key']) ? esc_attr($this->options['voyage_api_key']) : '';
8074 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8075
8076 echo '<div class="api-key-wrapper">';
8077 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 . '" />';
8078 echo '<button type="button" id="toggleVoyageAPIKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
8079 echo '<p class="description">' . esc_html__('Required for Voyage AI embedding models. Get your API key from Voyage AI.', 'mxchat') . '</p>';
8080 echo '</div>';
8081 }
8082
8083 public function mxchat_loops_api_key_callback() {
8084 $loops_api_key = isset($this->options['loops_api_key']) ? esc_attr($this->options['loops_api_key']) : '';
8085 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8086
8087 echo '<div class="api-key-wrapper">';
8088 echo sprintf(
8089 '<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" />',
8090 $loops_api_key,
8091 $nonce
8092 );
8093 echo '<button type="button" id="toggleLoopsApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
8094 echo '</div>';
8095 // Cross-reference back to where the list is chosen, so the two screens point
8096 // at each other (plan-mxchat-20260802-907a63).
8097 echo '<p class="description">' . wp_kses(
8098 sprintf(
8099 /* translators: %s: link to the Loops integration tab. */
8100 __('Required for Loops email integration. Get your API key from Loops.so, then choose your mailing list under %s.', 'mxchat'),
8101 '<a href="#integrations-loops">' . esc_html__('Integrations, Loops', 'mxchat') . '</a>'
8102 ),
8103 self::mxchat_loops_pointer_allowed_html()
8104 ) . '</p>';
8105 }
8106 public function mxchat_loops_mailing_list_callback() {
8107 // Add error handling and type checking
8108 $loops_api_key = '';
8109 $selected_list = '';
8110 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8111
8112 // Safely get the API key
8113 if (isset($this->options['loops_api_key']) && is_string($this->options['loops_api_key'])) {
8114 $loops_api_key = $this->options['loops_api_key'];
8115 }
8116
8117 // Safely get the selected list
8118 if (isset($this->options['loops_mailing_list']) && is_string($this->options['loops_mailing_list'])) {
8119 $selected_list = $this->options['loops_mailing_list'];
8120 }
8121
8122 if (!empty($loops_api_key)) {
8123 $lists = $this->mxchat_fetch_loops_mailing_lists($loops_api_key);
8124 if (is_array($lists) && !empty($lists)) {
8125 echo '<div class="mxchat-field-wrapper">';
8126 echo '<select id="loops_mailing_list" name="loops_mailing_list" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
8127
8128 // Add a default "Select a list" option
8129 echo '<option value="" ' . selected($selected_list, '', false) . '>' . esc_html__('Select a list', 'mxchat') . '</option>';
8130
8131 foreach ($lists as $list) {
8132 if (is_array($list) && isset($list['id']) && isset($list['name'])) {
8133 echo sprintf(
8134 '<option value="%s" %s>%s</option>',
8135 esc_attr($list['id']),
8136 selected($selected_list, $list['id'], false),
8137 esc_html($list['name'])
8138 );
8139 }
8140 }
8141 echo '</select>';
8142 echo '</div>';
8143 echo '<p class="description">' . esc_html__('Please select a mailing list to use with Loops.', 'mxchat') . '</p>';
8144 } else {
8145 echo '<p class="description">' . wp_kses(
8146 self::mxchat_loops_api_key_pointer(
8147 /* translators: %s: link to the API Keys tab. */
8148 __('No lists found. Please check your Loops API key under %s.', 'mxchat')
8149 ),
8150 self::mxchat_loops_pointer_allowed_html()
8151 ) . '</p>';
8152 }
8153 } else {
8154 echo '<p class="description">' . wp_kses(
8155 self::mxchat_loops_api_key_pointer(
8156 /* translators: %s: link to the API Keys tab. */
8157 __('Enter your Loops API key under %s to load mailing lists.', 'mxchat')
8158 ),
8159 self::mxchat_loops_pointer_allowed_html()
8160 ) . '</p>';
8161 }
8162 }
8163
8164 /**
8165 * Build the "API Keys" pointer used by the Loops mailing-list field.
8166 *
8167 * plan-mxchat-20260802-907a63. The Loops API key field was moved to the API
8168 * Keys tab, but this field's copy still read as though the input were beside
8169 * it, so users had nowhere to go. Both live on the SAME screen
8170 * (admin.php?page=mxchat-settings) — the key is under the API Keys tab, this
8171 * dropdown under Integrations > Loops — so the pointer is an in-page tab
8172 * switch, not a cross-page link.
8173 *
8174 * A plain href="#api-keys" is all it takes: the shared admin shell
8175 * (js/admin-sidebar.js) honours section-naming hashes on load AND on
8176 * hashchange (plan 4ede16), so ordinary anchors switch tabs — the old
8177 * data-target workaround on these pointer anchors is gone.
8178 *
8179 * @param string $template Translatable string containing one %s placeholder.
8180 * @return string
8181 */
8182 private static function mxchat_loops_api_key_pointer($template) {
8183 return sprintf(
8184 $template,
8185 '<a href="#api-keys">' . esc_html__('API Keys', 'mxchat') . '</a>'
8186 );
8187 }
8188
8189 /**
8190 * Allowed HTML for the Loops pointer — anchor plus the shell's tab-switch hook.
8191 */
8192 private static function mxchat_loops_pointer_allowed_html() {
8193 return array(
8194 'a' => array(
8195 'href' => array(),
8196 'data-target' => array(),
8197 ),
8198 );
8199 }
8200 public function mxchat_triggered_phrase_response_callback() {
8201 $default_response = __('Would you like to join our mailing list? Please provide your email below.', 'mxchat');
8202 $triggered_response = isset($this->options['triggered_phrase_response'])
8203 ? $this->options['triggered_phrase_response']
8204 : $default_response;
8205 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8206
8207 echo '<div class="mxchat-field-wrapper">';
8208 echo sprintf(
8209 '<textarea id="triggered_phrase_response" name="triggered_phrase_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
8210 $nonce,
8211 esc_textarea($triggered_response)
8212 );
8213 echo '</div>';
8214 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>';
8215 }
8216
8217 public function mxchat_email_capture_response_callback() {
8218 $default_response = __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
8219 $email_capture_response = isset($this->options['email_capture_response'])
8220 ? $this->options['email_capture_response']
8221 : $default_response;
8222 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8223
8224 echo '<div class="mxchat-field-wrapper">';
8225 echo sprintf(
8226 '<textarea id="email_capture_response" name="email_capture_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
8227 $nonce,
8228 esc_textarea($email_capture_response)
8229 );
8230 echo '</div>';
8231 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>';
8232 }
8233 public function mxchat_pre_chat_message_callback() {
8234 // Load the entire 'mxchat_options' array
8235 $all_options = get_option('mxchat_options', []);
8236
8237 // Retrieve the saved message or use the default value
8238 $default_message = __('Hey there! Ask me anything!', 'mxchat');
8239 $pre_chat_message = isset($all_options['pre_chat_message']) ? $all_options['pre_chat_message'] : $default_message;
8240
8241 // Output the textarea
8242 printf(
8243 '<textarea id="pre_chat_message" name="pre_chat_message" rows="5" cols="50">%s</textarea>',
8244 esc_textarea($pre_chat_message)
8245 );
8246 }
8247
8248 // Callback for AI Instructions textarea
8249 public function system_prompt_instructions_callback() {
8250 // Retrieve the current value of the system prompt instructions
8251 $instructions = isset($this->options['system_prompt_instructions']) ? esc_textarea($this->options['system_prompt_instructions']) : '';
8252 // Render the textarea field
8253 printf(
8254 '<textarea id="system_prompt_instructions" name="system_prompt_instructions" rows="5" cols="50">%s</textarea>',
8255 $instructions
8256 );
8257 // Personalization hint
8258 echo '<p class="description" style="margin-top: 8px;">';
8259 echo esc_html__('Use {visitor_name} to personalize AI responses when lead capture is enabled.', 'mxchat') . '<br>';
8260 echo '<code style="font-size: 12px;">' . esc_html__('Example: The visitor\'s name is {visitor_name}. Address them by name.', 'mxchat') . '</code>';
8261 echo '</p>';
8262 // Date awareness (plan cfcd80): the automatic line is the default; the
8263 // placeholders are for owners who want the date at a specific spot, with
8264 // the prompt-caching cost spelled out.
8265 echo '<p class="description" style="margin-top: 8px;">';
8266 echo esc_html__('The chatbot is told the current date and time in your site\'s timezone with every message, so it can tell past events from upcoming ones. You can also place {current_date} or {current_datetime} in the instructions above. Note: a placeholder changes the prompt once a day (date) or every minute (date and time), which reduces how much of it your AI provider can cache; the automatic line has no such cost, so most sites do not need one.', 'mxchat');
8267 echo '</p>';
8268 // Sample instructions button
8269 echo '<div class="mxchat-instructions-container">';
8270 echo '<button type="button" class="mxchat-instructions-btn" id="mxchatViewSampleBtn">';
8271 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">';
8272 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
8273 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
8274 echo '</svg>';
8275 echo esc_html__('View Sample Instructions', 'mxchat');
8276 echo '</button>';
8277 echo '</div>';
8278
8279 // Add modal to WordPress admin footer instead of inline
8280 add_action('admin_footer', array($this, 'render_sample_instructions_modal'));
8281 }
8282
8283 // New method to render modal in admin footer
8284 public function render_sample_instructions_modal() {
8285 static $modal_rendered = false;
8286 if ($modal_rendered) return; // Prevent duplicate modals
8287 $modal_rendered = true;
8288
8289 echo '<div class="mxchat-instructions-modal-overlay" id="mxchatSampleModal">';
8290 echo '<div class="mxchat-instructions-modal-content">';
8291 echo '<div class="mxchat-instructions-modal-header">';
8292 echo '<h3 class="mxchat-instructions-modal-title">';
8293 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">';
8294 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
8295 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
8296 echo '</svg>';
8297 echo esc_html__('Sample AI Instructions', 'mxchat');
8298 echo '</h3>';
8299 echo '<button type="button" class="mxchat-instructions-modal-close" id="mxchatModalClose">';
8300 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">';
8301 echo '<line x1="18" y1="6" x2="6" y2="18"/>';
8302 echo '<line x1="6" y1="6" x2="18" y2="18"/>';
8303 echo '</svg>';
8304 echo '</button>';
8305 echo '</div>';
8306 echo '<div class="mxchat-instructions-modal-body">';
8307 echo '<div class="mxchat-instructions-content">';
8308 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:
8309
8310 # Response Style - CRITICALLY IMPORTANT
8311 - MAXIMUM LENGTH: 1-3 short sentences per response
8312 - Ultra-concise: Get straight to the answer with no filler
8313 - No introductions like "Sure!" or "I\'d be happy to help"
8314 - No phrases like "based on my knowledge" or "according to information"
8315 - No explanatory text before giving the answer
8316 - No summaries or repetition
8317 - Hyperlink all URLs
8318 - Respond in user\'s language
8319 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
8320
8321 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
8322 - ONLY answer using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
8323 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
8324 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
8325 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
8326 - Better to admit insufficient information than provide inaccurate answers');
8327 echo '</div>';
8328 echo '<button type="button" class="mxchat-instructions-copy-btn" id="mxchatCopyBtn">';
8329 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">';
8330 echo '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>';
8331 echo '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>';
8332 echo '</svg>';
8333 echo esc_html__('Copy Instructions', 'mxchat');
8334 echo '</button>';
8335 echo '</div>';
8336 echo '<div class="mxchat-instructions-modal-footer">';
8337 echo '<button type="button" class="mxchat-instructions-btn-secondary" id="mxchatCloseBtn">' . esc_html__('Close', 'mxchat') . '</button>';
8338 echo '</div>';
8339 echo '</div>';
8340 echo '</div>';
8341 }
8342
8343
8344 public function mxchat_model_callback() {
8345 // Catalog refactor (plan-d14e89): single source of truth lives in
8346 // includes/class-mxchat-model-catalog.php. Dropdown groups are the
8347 // provider labels; each group maps model_id => "Label" strings.
8348 if (!class_exists('MxChat_Model_Catalog')) {
8349 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
8350 }
8351 // Retrieve the currently selected model from saved options
8352 $selected_model = isset($this->options['model']) ? esc_attr($this->options['model']) : 'gpt-5.6-sol';
8353
8354 // Pass the saved model so a provider-retired id keeps rendering as the
8355 // current selection instead of a blank select (plan e46b8f).
8356 $models = MxChat_Model_Catalog::settings_dropdown_groups($selected_model);
8357
8358 // Begin the select dropdown
8359 echo '<select id="model" name="model">';
8360
8361 // Iterate over groups of models
8362 foreach ($models as $group_label => $group_models) {
8363 echo '<optgroup label="' . esc_attr($group_label) . '">';
8364
8365 foreach ($group_models as $model_value => $model_label) {
8366 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
8367 }
8368
8369 echo '</optgroup>';
8370 }
8371
8372 echo '</select>';
8373
8374 // Add a note for OpenRouter
8375 echo '<p class="description" id="openrouter-model-note" style="display:none; color: #d63638; font-weight: 500;">';
8376 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle;"></span> ';
8377 echo esc_html__('After entering your OpenRouter API key above, click the button below to load available models.', 'mxchat');
8378 echo '</p>';
8379
8380 // API Key Status Messages (hidden by default, shown by JS based on selected model)
8381 $has_openai_key = !empty($this->options['api_key']);
8382 $has_claude_key = !empty($this->options['claude_api_key']);
8383 $has_xai_key = !empty($this->options['xai_api_key']);
8384 $has_deepseek_key = !empty($this->options['deepseek_api_key']);
8385 $has_gemini_key = !empty($this->options['gemini_api_key']);
8386 $has_openrouter_key = !empty($this->options['openrouter_api_key']);
8387
8388 // OpenAI/GPT models
8389 echo '<p class="mxchat-api-status" data-provider="openai" style="display:none;">';
8390 if ($has_openai_key) {
8391 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
8392 } else {
8393 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8394 }
8395 echo '</p>';
8396
8397 // Claude models
8398 echo '<p class="mxchat-api-status" data-provider="claude" style="display:none;">';
8399 if ($has_claude_key) {
8400 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Anthropic (Claude) detected', 'mxchat') . '</span>';
8401 } else {
8402 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Anthropic (Claude) detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8403 }
8404 echo '</p>';
8405
8406 // X.AI models
8407 echo '<p class="mxchat-api-status" data-provider="xai" style="display:none;">';
8408 if ($has_xai_key) {
8409 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for X.AI (Grok) detected', 'mxchat') . '</span>';
8410 } else {
8411 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>';
8412 }
8413 echo '</p>';
8414
8415 // DeepSeek models
8416 echo '<p class="mxchat-api-status" data-provider="deepseek" style="display:none;">';
8417 if ($has_deepseek_key) {
8418 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for DeepSeek detected', 'mxchat') . '</span>';
8419 } else {
8420 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for DeepSeek detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8421 }
8422 echo '</p>';
8423
8424 // Gemini models
8425 echo '<p class="mxchat-api-status" data-provider="gemini" style="display:none;">';
8426 if ($has_gemini_key) {
8427 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8428 } else {
8429 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8430 }
8431 echo '</p>';
8432
8433 // OpenRouter models
8434 echo '<p class="mxchat-api-status" data-provider="openrouter" style="display:none;">';
8435 if ($has_openrouter_key) {
8436 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenRouter detected', 'mxchat') . '</span>';
8437 } else {
8438 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenRouter detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8439 }
8440 echo '</p>';
8441
8442 // ADD THESE HIDDEN FIELDS RIGHT HERE:
8443 $openrouter_model = isset($this->options['openrouter_selected_model']) ? esc_attr($this->options['openrouter_selected_model']) : '';
8444 $openrouter_model_name = isset($this->options['openrouter_selected_model_name']) ? esc_attr($this->options['openrouter_selected_model_name']) : '';
8445
8446 echo '<input type="hidden" id="openrouter_selected_model" name="openrouter_selected_model" value="' . $openrouter_model . '" />';
8447 echo '<input type="hidden" id="openrouter_selected_model_name" name="openrouter_selected_model_name" value="' . $openrouter_model_name . '" />';
8448 }
8449
8450 // Update your existing callback method
8451 public function enable_streaming_toggle_callback() {
8452 // Get value from options array, default to 'on'
8453 $enabled = isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on';
8454 $checked = ($enabled === 'on') ? 'checked' : '';
8455
8456 echo '<label class="toggle-switch">';
8457 echo sprintf(
8458 '<input type="checkbox" id="enable_streaming_toggle" name="enable_streaming_toggle" value="on" %s />',
8459 esc_attr($checked)
8460 );
8461 echo '<span class="slider"></span>';
8462 echo '</label>';
8463
8464 // Test button — branded .mxch-btn with inline SVG (IDs preserved for AJAX binding)
8465 echo '<div class="mxch-streaming-test-row">';
8466 echo '<button type="button" id="mxchat-test-streaming-btn" class="mxch-btn mxch-btn-secondary">';
8467 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>';
8468 echo esc_html__('Test Streaming Compatibility', 'mxchat');
8469 echo '</button>';
8470 echo '<p id="mxchat-test-streaming-result" class="mxch-streaming-test-result"></p>';
8471 echo '</div>';
8472 }
8473
8474 // Web Search toggle callback
8475 public function enable_web_search_toggle_callback() {
8476 // Get value from options array, default to 'off'
8477 $enabled = isset($this->options['enable_web_search']) ? $this->options['enable_web_search'] : 'off';
8478 $checked = ($enabled === 'on') ? 'checked' : '';
8479
8480 // Get current model to determine if we should show/enable the toggle
8481 $current_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
8482
8483 // Models that DON'T support web search — OpenAI-docs-driven exception list.
8484 // Keep hardcoded; the catalog can't infer "supports web search" per-model, so any
8485 // future OpenAI model that lacks Responses-API web_search support is added here.
8486 $unsupported_models = array('gpt-4.1-nano');
8487
8488 // Web-search-capable chat-model allowlists, derived from the central model catalog
8489 // (class-mxchat-model-catalog.php). When a new OpenAI/Gemini chat model is added there,
8490 // the Web Search toggle picks it up automatically — no edit here.
8491 // OpenAI grounds via the Responses-API web_search tool; Gemini grounds natively via the
8492 // Google Search tool (plan 46b9ea wired the Gemini dispatch — every shipped Gemini chat
8493 // model is 2.x/3.x and grounds, matching that path's empty opt-out list, so all catalog
8494 // Gemini models are supported here).
8495 if (!class_exists('MxChat_Model_Catalog')) {
8496 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
8497 }
8498 $chat_catalog = MxChat_Model_Catalog::chat_models();
8499 $openai_models = (isset($chat_catalog['openai']['models']) && is_array($chat_catalog['openai']['models']))
8500 ? array_keys($chat_catalog['openai']['models'])
8501 : array();
8502 $gemini_models = (isset($chat_catalog['gemini']['models']) && is_array($chat_catalog['gemini']['models']))
8503 ? array_keys($chat_catalog['gemini']['models'])
8504 : array();
8505
8506 $is_capable = in_array($current_model, $openai_models) || in_array($current_model, $gemini_models);
8507 $is_supported = $is_capable && !in_array($current_model, $unsupported_models);
8508
8509 // Wrapper div with data attributes for JS to show/hide. data-openai-models is kept for
8510 // back-compat; data-gemini-models is the added second provider the JS now also honors.
8511 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;"' : '') . '>';
8512
8513 echo '<label class="toggle-switch">';
8514 echo sprintf(
8515 '<input type="checkbox" id="enable_web_search" name="enable_web_search" value="on" %s />',
8516 esc_attr($checked)
8517 );
8518 echo '<span class="slider"></span>';
8519 echo '</label>';
8520
8521 echo '</div>';
8522
8523 // Message shown when a model that can't ground (Claude, Grok, DeepSeek, OpenRouter, etc.) is selected
8524 echo '<p id="web-search-unavailable-message" class="description" style="color: #666;' . ($is_supported ? ' display:none;' : '') . '">';
8525 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle; margin-right: 4px;"></span>';
8526 echo esc_html__('Web search is only available for OpenAI and Gemini models.', 'mxchat');
8527 echo '</p>';
8528 }
8529
8530 // AJAX handler to fetch OpenRouter models
8531 public function fetch_openrouter_models() {
8532 check_ajax_referer('mxchat_fetch_openrouter_models', 'nonce');
8533
8534 // plan-mxchat-20260731-c63fb6 — nonce is not authorization.
8535 if (!current_user_can('manage_options')) {
8536 wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat')), 403);
8537 }
8538
8539 $api_key = isset($_POST['api_key']) ? sanitize_text_field($_POST['api_key']) : '';
8540
8541 if (empty($api_key)) {
8542 wp_send_json_error(array('message' => 'API key is required'));
8543 }
8544
8545 $response = wp_remote_get('https://openrouter.ai/api/v1/models', array(
8546 'headers' => array(
8547 'Authorization' => 'Bearer ' . $api_key,
8548 'Content-Type' => 'application/json',
8549 ),
8550 'timeout' => 15,
8551 ));
8552
8553 if (is_wp_error($response)) {
8554 wp_send_json_error(array('message' => $response->get_error_message()));
8555 }
8556
8557 $body = wp_remote_retrieve_body($response);
8558 $data = json_decode($body, true);
8559
8560 if (isset($data['data']) && is_array($data['data'])) {
8561 // Format the models for the frontend
8562 $models = array_map(function($model) {
8563 return array(
8564 'id' => $model['id'],
8565 'name' => $model['name'] ?? $model['id'],
8566 'description' => $model['description'] ?? '',
8567 'context_length' => $model['context_length'] ?? 0,
8568 'pricing' => array(
8569 'prompt' => $model['pricing']['prompt'] ?? 0,
8570 'completion' => $model['pricing']['completion'] ?? 0,
8571 ),
8572 );
8573 }, $data['data']);
8574
8575 wp_send_json_success(array('models' => $models));
8576 } else {
8577 wp_send_json_error(array('message' => 'Invalid response from OpenRouter'));
8578 }
8579 }
8580
8581 // Callback function for embedding model selection
8582 public function embedding_model_callback() {
8583 $models = array(
8584 esc_html__('OpenAI Embeddings', 'mxchat') => array(
8585 'text-embedding-3-small' => esc_html__('TE3 Small (1536, Efficient)', 'mxchat'),
8586 'text-embedding-ada-002' => esc_html__('Ada 2 (1536, Recommended)', 'mxchat'),
8587 'text-embedding-3-large' => esc_html__('TE3 Large (3072, Powerful)', 'mxchat'),
8588 ),
8589 esc_html__('Voyage AI Embeddings', 'mxchat') => array(
8590 'voyage-3-large' => esc_html__('Voyage-3 Large (2048, Most Capable)', 'mxchat'),
8591 ),
8592 esc_html__('Google Gemini Embeddings', 'mxchat') => array(
8593 'gemini-embedding-001' => esc_html__('Gemini Embedding (1536, Stable)', 'mxchat'),
8594 )
8595 );
8596 $selected_model = isset($this->options['embedding_model']) ? esc_attr($this->options['embedding_model']) : 'text-embedding-ada-002';
8597 // With custom-provider embeddings on, this picker is inert — the effective
8598 // model comes from the Custom Embedding Model field (plan ae02cb). Autosave
8599 // fires only on user change events, so the disabled attribute cannot cause
8600 // a missing-key save; JS keeps the state in sync with the toggle live.
8601 $custom_embeddings_on = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
8602 echo '<select id="embedding_model" name="embedding_model"' . ($custom_embeddings_on ? ' disabled' : '') . '>';
8603 foreach ($models as $group_label => $group_models) {
8604 echo '<optgroup label="' . esc_attr($group_label) . '">';
8605 foreach ($group_models as $model_value => $model_label) {
8606 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
8607 }
8608 echo '</optgroup>';
8609 }
8610 echo '</select>';
8611 echo '<p class="mxch-field-description mxchat-embedding-custom-note" id="mxchat_embedding_custom_note"' . ($custom_embeddings_on ? '' : ' style="display:none;"') . '>';
8612 echo esc_html__('Custom provider embeddings are in use — the model is set in the Custom Embedding Model field under Custom Provider.', 'mxchat');
8613 echo '</p>';
8614
8615 // API Key Status Messages for Embedding Models
8616 $has_openai_key = !empty($this->options['api_key']);
8617 $has_voyage_key = !empty($this->options['voyage_api_key']);
8618 $has_gemini_key = !empty($this->options['gemini_api_key']);
8619
8620 // OpenAI Embeddings
8621 echo '<p class="mxchat-embedding-api-status" data-provider="openai" style="display:none;">';
8622 if ($has_openai_key) {
8623 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
8624 } else {
8625 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8626 }
8627 echo '</p>';
8628
8629 // Voyage AI Embeddings
8630 echo '<p class="mxchat-embedding-api-status" data-provider="voyage" style="display:none;">';
8631 if ($has_voyage_key) {
8632 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Voyage AI detected', 'mxchat') . '</span>';
8633 } else {
8634 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Voyage AI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8635 }
8636 echo '</p>';
8637
8638 // Gemini Embeddings
8639 echo '<p class="mxchat-embedding-api-status" data-provider="gemini" style="display:none;">';
8640 if ($has_gemini_key) {
8641 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8642 } else {
8643 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8644 }
8645 echo '</p>';
8646
8647 }
8648
8649
8650 public function mxchat_top_bar_title_callback() {
8651 // Retrieve the current value of the top bar title from saved options
8652 $top_bar_title = isset($this->options['top_bar_title']) ? esc_attr($this->options['top_bar_title']) : '';
8653
8654 // Render the input field
8655 echo '<input type="text" id="top_bar_title" name="top_bar_title" value="' . $top_bar_title . '" />';
8656 }
8657 public function mxchat_ai_agent_text_callback() {
8658 // Retrieve the current value of the AI agent text from saved options
8659 $ai_agent_text = isset($this->options['ai_agent_text']) ? esc_attr($this->options['ai_agent_text']) : '';
8660 // Render the input field
8661 echo '<input type="text" id="ai_agent_text" name="ai_agent_text" value="' . $ai_agent_text . '" />';
8662 }
8663
8664
8665 public function enable_email_block_callback() {
8666 // Load full plugin options array
8667 $all_options = get_option('mxchat_options', []);
8668
8669 // Get the value, default to 'off'
8670 $enable_email_block = isset($all_options['enable_email_block']) ? $all_options['enable_email_block'] : 'off';
8671
8672 // Check if it's 'on'
8673 $checked = ($enable_email_block === 'on') ? 'checked' : '';
8674
8675 echo '<label class="toggle-switch">';
8676 echo sprintf(
8677 '<input type="checkbox" id="enable_email_block" name="enable_email_block" value="on" %s />',
8678 esc_attr($checked)
8679 );
8680 echo '<span class="slider"></span>';
8681 echo '</label>';
8682 }
8683
8684 public function email_blocker_header_content_callback() {
8685 // Load the entire 'mxchat_options' array
8686 $all_options = get_option('mxchat_options', []);
8687
8688 // Retrieve the saved content or default to empty
8689 $content = isset($all_options['email_blocker_header_content'])
8690 ? $all_options['email_blocker_header_content']
8691 : '';
8692
8693 // Render the textarea - IMPORTANT: name should be just "email_blocker_header_content"
8694 echo '<textarea
8695 id="email_blocker_header_content"
8696 name="email_blocker_header_content"
8697 rows="5"
8698 cols="70"
8699 data-setting="email_blocker_header_content"
8700 >' . esc_textarea($content) . '</textarea>';
8701 }
8702
8703 public function email_blocker_button_text_callback() {
8704 // Load the entire 'mxchat_options' array
8705 $all_options = get_option('mxchat_options', []);
8706
8707 // Retrieve the saved button text or default to empty
8708 $button_text = isset($all_options['email_blocker_button_text'])
8709 ? $all_options['email_blocker_button_text']
8710 : '';
8711
8712 // Use esc_attr to safely render the existing text
8713 echo '<input type="text" id="email_blocker_button_text" name="email_blocker_button_text" value="' . esc_attr($button_text) . '" style="width: 300px;" />';
8714 }
8715
8716 //Enable name field callback
8717 public function enable_name_field_callback() {
8718 // Load full plugin options array
8719 $all_options = get_option('mxchat_options', []);
8720 // Get the value, default to 'off'
8721 $enable_name_field = isset($all_options['enable_name_field']) ? $all_options['enable_name_field'] : 'off';
8722 // Check if it's 'on'
8723 $checked = ($enable_name_field === 'on') ? 'checked' : '';
8724 echo '<label class="toggle-switch">';
8725 echo sprintf(
8726 '<input type="checkbox" id="enable_name_field" name="enable_name_field" value="on" %s />',
8727 esc_attr($checked)
8728 );
8729 echo '<span class="slider"></span>';
8730 echo '</label>';
8731 }
8732 //Name field placeholder callback
8733 public function name_field_placeholder_callback() {
8734 $all_options = get_option('mxchat_options', []);
8735 $placeholder = isset($all_options['name_field_placeholder'])
8736 ? $all_options['name_field_placeholder']
8737 : esc_html__('Enter your name', 'mxchat');
8738
8739 echo '<input type="text" id="name_field_placeholder" name="name_field_placeholder" value="' . esc_attr($placeholder) . '" style="width: 300px;" />';
8740 }
8741
8742 // Consent checkbox toggle (b062c4)
8743 public function enable_consent_checkbox_callback() {
8744 $all_options = get_option('mxchat_options', []);
8745 $enabled = isset($all_options['enable_consent_checkbox']) ? $all_options['enable_consent_checkbox'] : 'off';
8746 $checked = ($enabled === 'on') ? 'checked' : '';
8747 echo '<label class="toggle-switch">';
8748 echo sprintf(
8749 '<input type="checkbox" id="enable_consent_checkbox" name="enable_consent_checkbox" value="on" %s />',
8750 esc_attr($checked)
8751 );
8752 echo '<span class="slider"></span>';
8753 echo '</label>';
8754 }
8755
8756 // Consent checkbox label (b062c4). Owner content; a safe HTML subset is
8757 // allowed (anchor + inline emphasis) so the Privacy Policy can be linked.
8758 public function consent_checkbox_label_callback() {
8759 $all_options = get_option('mxchat_options', []);
8760 $label = isset($all_options['consent_checkbox_label'])
8761 ? $all_options['consent_checkbox_label']
8762 : __('I agree to the Privacy Policy.', 'mxchat');
8763
8764 echo '<textarea
8765 id="consent_checkbox_label"
8766 name="consent_checkbox_label"
8767 rows="2"
8768 cols="70"
8769 data-setting="consent_checkbox_label"
8770 >' . esc_textarea($label) . '</textarea>';
8771 }
8772
8773 // Consent required toggle (b062c4)
8774 public function consent_checkbox_required_callback() {
8775 $all_options = get_option('mxchat_options', []);
8776 $required = isset($all_options['consent_checkbox_required']) ? $all_options['consent_checkbox_required'] : 'off';
8777 $checked = ($required === 'on') ? 'checked' : '';
8778 echo '<label class="toggle-switch">';
8779 echo sprintf(
8780 '<input type="checkbox" id="consent_checkbox_required" name="consent_checkbox_required" value="on" %s />',
8781 esc_attr($checked)
8782 );
8783 echo '<span class="slider"></span>';
8784 echo '</label>';
8785 }
8786
8787 // Show the pre-chat form to logged-in visitors too (c0cfaf). Default OFF —
8788 // today the form short-circuits on is_user_logged_in(), which means a
8789 // membership or LMS site where everyone is signed in never captures a lead
8790 // and never shows the consent checkbox.
8791 public function lead_capture_logged_in_toggle_callback() {
8792 $all_options = get_option('mxchat_options', []);
8793 $enabled = isset($all_options['lead_capture_logged_in_toggle']) ? $all_options['lead_capture_logged_in_toggle'] : 'off';
8794 $checked = ($enabled === 'on') ? 'checked' : '';
8795 echo '<label class="toggle-switch">';
8796 echo sprintf(
8797 '<input type="checkbox" id="lead_capture_logged_in_toggle" name="lead_capture_logged_in_toggle" value="on" %s />',
8798 esc_attr($checked)
8799 );
8800 echo '<span class="slider"></span>';
8801 echo '</label>';
8802 }
8803
8804 // Require an email address to submit the pre-chat form (c0cfaf). Default ON,
8805 // which is today's behaviour. Turning it off lets an owner collect a name
8806 // (and consent) without demanding an address.
8807 public function lead_capture_require_email_toggle_callback() {
8808 $all_options = get_option('mxchat_options', []);
8809 $required = isset($all_options['lead_capture_require_email_toggle']) ? $all_options['lead_capture_require_email_toggle'] : 'on';
8810 $checked = ($required === 'on') ? 'checked' : '';
8811 echo '<label class="toggle-switch">';
8812 echo sprintf(
8813 '<input type="checkbox" id="lead_capture_require_email_toggle" name="lead_capture_require_email_toggle" value="on" %s />',
8814 esc_attr($checked)
8815 );
8816 echo '<span class="slider"></span>';
8817 echo '</label>';
8818 }
8819
8820
8821
8822 public function mxchat_intro_message_callback() {
8823 // Load the entire 'mxchat_options' array
8824 $all_options = get_option('mxchat_options', []);
8825 // Retrieve the saved intro message or use the default
8826 $default_message = __('Hello! How can I assist you today?', 'mxchat');
8827 $saved_message = isset($all_options['intro_message']) ? $all_options['intro_message'] : $default_message;
8828 // Escape on output (esc_textarea) — neutralizes any payload already stored before the
8829 // Wordfence Stored-XSS fix (CWE-79, plan-3f8158) and prevents </textarea> context-breakout.
8830 ?>
8831 <textarea id="intro_message" name="intro_message" rows="5" cols="50"><?php echo esc_textarea( $saved_message ); ?></textarea>
8832 <p class="description" style="margin-top: 8px;">
8833 <?php esc_html_e('Use {visitor_name} to personalize greetings when lead capture is enabled.', 'mxchat'); ?><br>
8834 <code style="font-size: 12px;"><?php esc_html_e('Example: Hello {visitor_name}! How can I help you today?', 'mxchat'); ?></code>
8835 </p>
8836 <?php
8837 }
8838
8839 public function mxchat_input_copy_callback() {
8840 // Load the entire 'mxchat_options' array
8841 $all_options = get_option('mxchat_options', []);
8842
8843 // Retrieve the saved input copy or use the default value
8844 $default_copy = __('How can I assist?', 'mxchat');
8845 $input_copy = isset($all_options['input_copy']) ? $all_options['input_copy'] : $default_copy;
8846
8847 // Output the input field with the saved value
8848 printf(
8849 '<input type="text" id="input_copy" name="input_copy" value="%s" placeholder="%s" />',
8850 esc_attr($input_copy),
8851 esc_attr__('How can I assist?', 'mxchat')
8852 );
8853 }
8854
8855
8856 public function mxchat_append_to_body_callback() {
8857 // Fetch fresh options to ensure we have the latest saved values
8858 $options = get_option('mxchat_options', array());
8859
8860 // Get value from options array, default to 'off'
8861 $append_to_body = isset($options['append_to_body']) ? $options['append_to_body'] : 'off';
8862 $checked = ($append_to_body === 'on') ? 'checked' : '';
8863
8864 // Get post type visibility settings
8865 $visibility_mode = isset($options['post_type_visibility_mode']) ? $options['post_type_visibility_mode'] : 'all';
8866 $visibility_list = isset($options['post_type_visibility_list']) ? $options['post_type_visibility_list'] : array();
8867 if (!is_array($visibility_list)) {
8868 $visibility_list = array();
8869 }
8870
8871 echo '<div class="mxchat-autosave-section">';
8872
8873 // Main toggle
8874 echo '<label class="toggle-switch">';
8875 echo sprintf(
8876 '<input type="checkbox" id="append_to_body" name="append_to_body" value="on" %s />',
8877 esc_attr($checked)
8878 );
8879 echo '<span class="slider"></span>';
8880 echo '</label>';
8881
8882 // Post Type Visibility Options (only visible when auto-display is ON)
8883 $display_style = ($append_to_body === 'on') ? '' : 'display: none;';
8884 echo '<div id="post-type-visibility-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8885
8886 echo '<div class="mxchat-post-type-visibility-header">';
8887 echo '<h4>' . esc_html__('Post Type Visibility', 'mxchat') . '</h4>';
8888 echo '</div>';
8889
8890 // Mode selector (radio buttons)
8891 echo '<div class="mxchat-visibility-mode">';
8892
8893 echo '<label class="mxchat-radio-label">';
8894 echo '<input type="radio" name="post_type_visibility_mode" value="all" ' . checked($visibility_mode, 'all', false) . ' />';
8895 echo '<span>' . esc_html__('Show on all post types', 'mxchat') . '</span>';
8896 echo '</label>';
8897
8898 echo '<label class="mxchat-radio-label">';
8899 echo '<input type="radio" name="post_type_visibility_mode" value="include" ' . checked($visibility_mode, 'include', false) . ' />';
8900 echo '<span>' . esc_html__('Only show on selected post types', 'mxchat') . '</span>';
8901 echo '</label>';
8902
8903 echo '<label class="mxchat-radio-label">';
8904 echo '<input type="radio" name="post_type_visibility_mode" value="exclude" ' . checked($visibility_mode, 'exclude', false) . ' />';
8905 echo '<span>' . esc_html__('Hide on selected post types', 'mxchat') . '</span>';
8906 echo '</label>';
8907
8908 echo '</div>';
8909
8910 // Post type checkboxes (only visible when mode is include or exclude)
8911 $list_display = ($visibility_mode !== 'all') ? '' : 'display: none;';
8912 echo '<div id="post-type-list" class="mxchat-post-type-list" style="' . esc_attr($list_display) . '">';
8913
8914 // Get all public post types
8915 $post_types = get_post_types(array('public' => true), 'objects');
8916
8917 foreach ($post_types as $post_type) {
8918 // Skip attachments
8919 if ($post_type->name === 'attachment') {
8920 continue;
8921 }
8922
8923 $is_checked = in_array($post_type->name, $visibility_list) ? 'checked' : '';
8924
8925 echo '<label class="mxchat-checkbox-label">';
8926 echo '<input type="checkbox" name="post_type_visibility_list[]" value="' . esc_attr($post_type->name) . '" ' . $is_checked . ' />';
8927 echo '<span>' . esc_html($post_type->label) . '</span>';
8928 echo '</label>';
8929 }
8930
8931 echo '</div>'; // End post-type-list
8932 echo '</div>'; // End post-type-visibility-options
8933 echo '</div>'; // End mxchat-autosave-section
8934 }
8935
8936 public function mxchat_contextual_awareness_callback() {
8937 // Get value from options array, default to 'off'
8938 $contextual_awareness = isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off';
8939 $checked = ($contextual_awareness === 'on') ? 'checked' : '';
8940 echo '<label class="toggle-switch">';
8941 echo sprintf(
8942 '<input type="checkbox" id="contextual_awareness_toggle" name="contextual_awareness_toggle" value="on" %s />',
8943 esc_attr($checked)
8944 );
8945 echo '<span class="slider"></span>';
8946 echo '</label>';
8947 }
8948
8949 public function mxchat_citation_links_toggle_callback() {
8950 // Get value from options array, default to 'on' (enabled by default)
8951 $citation_links = isset($this->options['citation_links_toggle']) ? $this->options['citation_links_toggle'] : 'on';
8952 $checked = ($citation_links === 'on') ? 'checked' : '';
8953 echo '<label class="toggle-switch">';
8954 echo sprintf(
8955 '<input type="checkbox" id="citation_links_toggle" name="citation_links_toggle" value="on" %s />',
8956 esc_attr($checked)
8957 );
8958 echo '<span class="slider"></span>';
8959 echo '</label>';
8960 }
8961
8962 /**
8963 * "Strip unapproved links" toggle (plan 58f8b4). Until the site saves an
8964 * explicit value the rendered state is the install-stamp default — on for
8965 * installs born at 3.2.20+, off for upgrades — so what the admin sees here
8966 * always matches what the response URL guard is actually doing.
8967 */
8968 public function mxchat_strip_unapproved_links_toggle_callback() {
8969 if (isset($this->options['strip_unapproved_links_toggle'])) {
8970 $strip_links = $this->options['strip_unapproved_links_toggle'];
8971 } else {
8972 $strip_links = function_exists('mxchat_strip_unapproved_links_default')
8973 ? mxchat_strip_unapproved_links_default()
8974 : 'off';
8975 }
8976 $checked = ($strip_links === 'on') ? 'checked' : '';
8977 echo '<label class="toggle-switch">';
8978 echo sprintf(
8979 '<input type="checkbox" id="strip_unapproved_links_toggle" name="strip_unapproved_links_toggle" value="on" %s />',
8980 esc_attr($checked)
8981 );
8982 echo '<span class="slider"></span>';
8983 echo '</label>';
8984 }
8985
8986 /**
8987 * Toggle for the end-of-session satisfaction rating prompt (plan-a5b006).
8988 * Default ON. The widget reads this through the localized object; the
8989 * mxchat_satisfaction_rating_enabled filter still lets developers force
8990 * the value site-wide.
8991 *
8992 * The 5 customization fields (idle/question/thanks/placeholder/saved) are
8993 * rendered inline here inside a single wrapper div whose initial display
8994 * is set server-side from the toggle value (plan-29caac). Mirrors the
8995 * auto-display chatbot pattern at mxchat_append_to_body_callback — no
8996 * DOMContentLoaded race because rows exist as direct children of this
8997 * callback's output, and the wrapper's display: none is inline at render
8998 * time so refresh shows the correct state with no flash.
8999 */
9000 public function mxchat_satisfaction_rating_toggle_callback() {
9001 $options = $this->options;
9002 $value = isset($options['satisfaction_rating_enabled']) ? $options['satisfaction_rating_enabled'] : 'off';
9003 $checked = ($value === 'on') ? 'checked' : '';
9004
9005 $idle = isset($options['satisfaction_rating_idle_seconds']) ? intval($options['satisfaction_rating_idle_seconds']) : 60;
9006 $idle = max(5, min(600, $idle));
9007 $question = isset($options['satisfaction_rating_question']) ? $options['satisfaction_rating_question'] : '';
9008 $thanks = isset($options['satisfaction_rating_thanks']) ? $options['satisfaction_rating_thanks'] : '';
9009 $placeholder = isset($options['satisfaction_rating_placeholder']) ? $options['satisfaction_rating_placeholder'] : '';
9010 $saved = isset($options['satisfaction_rating_saved']) ? $options['satisfaction_rating_saved'] : '';
9011
9012 echo '<div class="mxchat-autosave-section">';
9013
9014 echo '<label class="toggle-switch">';
9015 echo sprintf(
9016 '<input type="checkbox" id="satisfaction_rating_enabled" name="satisfaction_rating_enabled" value="on" %s />',
9017 esc_attr($checked)
9018 );
9019 echo '<span class="slider"></span>';
9020 echo '</label>';
9021
9022 ?>
9023 <style>
9024 .mxchat-sub-options-field { margin: 12px 0; }
9025 .mxchat-sub-options-field label { display: inline-block; margin-bottom: 4px; }
9026 #satisfaction-rating-sub-options h4 { margin: 16px 0 8px; }
9027 </style>
9028 <?php
9029
9030 $display_style = ($value === 'on') ? '' : 'display: none;';
9031 echo '<div id="satisfaction-rating-sub-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
9032
9033 echo '<h4>' . esc_html__('Customize the prompt (optional)', 'mxchat') . '</h4>';
9034
9035 echo '<div class="mxchat-sub-options-field">';
9036 echo '<label for="satisfaction_rating_idle_seconds"><strong>' . esc_html__('Idle Timeout', 'mxchat') . '</strong></label><br />';
9037 printf(
9038 '<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>',
9039 (int) $idle,
9040 esc_html__('seconds of user inactivity before the prompt appears (5-600)', 'mxchat')
9041 );
9042 echo '</div>';
9043
9044 echo '<div class="mxchat-sub-options-field">';
9045 echo '<label for="satisfaction_rating_question"><strong>' . esc_html__('Prompt Question', 'mxchat') . '</strong></label><br />';
9046 printf(
9047 '<input type="text" id="satisfaction_rating_question" name="satisfaction_rating_question" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
9048 esc_attr($question),
9049 esc_attr__('Was this helpful?', 'mxchat')
9050 );
9051 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown above the thumbs up/down.', 'mxchat') . '</p>';
9052 echo '</div>';
9053
9054 echo '<div class="mxchat-sub-options-field">';
9055 echo '<label for="satisfaction_rating_thanks"><strong>' . esc_html__('Thank-You Message', 'mxchat') . '</strong></label><br />';
9056 printf(
9057 '<input type="text" id="satisfaction_rating_thanks" name="satisfaction_rating_thanks" value="%s" maxlength="300" class="regular-text" placeholder="%s" />',
9058 esc_attr($thanks),
9059 esc_attr__('Thanks! Anything we should improve? (optional)', 'mxchat')
9060 );
9061 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the user clicks a thumb.', 'mxchat') . '</p>';
9062 echo '</div>';
9063
9064 echo '<div class="mxchat-sub-options-field">';
9065 echo '<label for="satisfaction_rating_placeholder"><strong>' . esc_html__('Feedback Placeholder', 'mxchat') . '</strong></label><br />';
9066 printf(
9067 '<input type="text" id="satisfaction_rating_placeholder" name="satisfaction_rating_placeholder" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
9068 esc_attr($placeholder),
9069 esc_attr__('Tell us what could be better…', 'mxchat')
9070 );
9071 echo '<p class="description">' . esc_html__('Leave blank for the default. Placeholder text inside the feedback textarea.', 'mxchat') . '</p>';
9072 echo '</div>';
9073
9074 echo '<div class="mxchat-sub-options-field">';
9075 echo '<label for="satisfaction_rating_saved"><strong>' . esc_html__('Saved Confirmation', 'mxchat') . '</strong></label><br />';
9076 printf(
9077 '<input type="text" id="satisfaction_rating_saved" name="satisfaction_rating_saved" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
9078 esc_attr($saved),
9079 esc_attr__('Thanks for the feedback.', 'mxchat')
9080 );
9081 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the feedback is sent.', 'mxchat') . '</p>';
9082 echo '</div>';
9083
9084 echo '</div>'; // #satisfaction-rating-sub-options
9085 echo '</div>'; // .mxchat-autosave-section
9086
9087 ?>
9088 <script>
9089 (function() {
9090 document.addEventListener('DOMContentLoaded', function() {
9091 var toggle = document.getElementById('satisfaction_rating_enabled');
9092 var subOptions = document.getElementById('satisfaction-rating-sub-options');
9093 if (!toggle || !subOptions) return;
9094 toggle.addEventListener('change', function() {
9095 subOptions.style.display = toggle.checked ? '' : 'none';
9096 });
9097 });
9098 })();
9099 </script>
9100 <?php
9101 }
9102
9103 public function mxchat_privacy_toggle_callback() {
9104 // Load from mxchat_options array
9105 $options = get_option('mxchat_options', []);
9106
9107 // Get privacy toggle value with fallback
9108 $privacy_toggle = isset($options['privacy_toggle']) ? $options['privacy_toggle'] : 'off';
9109 $checked = ($privacy_toggle === 'on') ? 'checked' : '';
9110
9111 // Get privacy text with fallback
9112 $privacy_text = isset($options['privacy_text'])
9113 ? $options['privacy_text']
9114 : __('By chatting, you agree to our <a href="https://example.com/privacy-policy" target="_blank">privacy policy</a>.', 'mxchat');
9115
9116 // Output the toggle switch
9117 echo '<label class="toggle-switch">';
9118 echo sprintf(
9119 '<input type="checkbox" id="privacy_toggle" name="privacy_toggle" value="on" %s />',
9120 esc_attr($checked)
9121 );
9122 echo '<span class="slider"></span>';
9123 echo '</label>';
9124
9125 // Output the custom text input field
9126 echo sprintf(
9127 '<textarea id="privacy_text" name="privacy_text" rows="5" cols="50" class="regular-text">%s</textarea>',
9128 esc_textarea($privacy_text)
9129 );
9130 }
9131
9132
9133 public function mxchat_complianz_toggle_callback() {
9134 // Load from mxchat_options array
9135 $options = get_option('mxchat_options', []);
9136
9137 // Get complianz toggle value with fallback
9138 $complianz_toggle = isset($options['complianz_toggle']) ? $options['complianz_toggle'] : 'off';
9139 $checked = ($complianz_toggle === 'on') ? 'checked' : '';
9140
9141 // Output the toggle switch
9142 echo '<label class="toggle-switch">';
9143 echo sprintf(
9144 '<input type="checkbox" id="complianz_toggle" name="complianz_toggle" value="on" %s />',
9145 esc_attr($checked)
9146 );
9147 echo '<span class="slider"></span>';
9148 echo '</label>';
9149 }
9150
9151 public function mxchat_link_target_toggle_callback() {
9152 // Load from mxchat_options array
9153 $options = get_option('mxchat_options', []);
9154
9155 // Get link target toggle value with fallback
9156 $link_target_toggle = isset($options['link_target_toggle']) ? $options['link_target_toggle'] : 'off';
9157 $checked = ($link_target_toggle === 'on') ? 'checked' : '';
9158
9159 // Output the toggle switch
9160 echo '<label class="toggle-switch">';
9161 echo sprintf(
9162 '<input type="checkbox" id="link_target_toggle" name="link_target_toggle" value="on" %s />',
9163 esc_attr($checked)
9164 );
9165 echo '<span class="slider"></span>';
9166 echo '</label>';
9167 }
9168
9169 public function mxchat_chat_persistence_toggle_callback() {
9170 // Load from mxchat_options array
9171 $options = get_option('mxchat_options', []);
9172
9173 // Get chat persistence toggle value with fallback
9174 $chat_persistence_toggle = isset($options['chat_persistence_toggle']) ? $options['chat_persistence_toggle'] : 'off';
9175 $checked = ($chat_persistence_toggle === 'on') ? 'checked' : '';
9176
9177 // Output the toggle switch
9178 echo '<label class="toggle-switch">';
9179 echo sprintf(
9180 '<input type="checkbox" id="chat_persistence_toggle" name="chat_persistence_toggle" value="on" %s />',
9181 esc_attr($checked)
9182 );
9183 echo '<span class="slider"></span>';
9184 echo '</label>';
9185 }
9186
9187 public function mxchat_print_button_toggle_callback() {
9188 // Load from mxchat_options array
9189 $options = get_option('mxchat_options', []);
9190
9191 // Default ON — the option was previously unexposed and the button always showed.
9192 $print_button_enabled = isset($options['print_button_enabled']) ? $options['print_button_enabled'] : 'on';
9193 $checked = ($print_button_enabled === 'on') ? 'checked' : '';
9194
9195 // Output the toggle switch
9196 echo '<label class="toggle-switch">';
9197 echo sprintf(
9198 '<input type="checkbox" id="print_button_enabled" name="print_button_enabled" value="on" %s />',
9199 esc_attr($checked)
9200 );
9201 echo '<span class="slider"></span>';
9202 echo '</label>';
9203 }
9204
9205 /**
9206 * Editor Assistant toggle (plan-8cb0cb). Standalone option, NOT in mxchat_options
9207 * (bypasses the mxchat_sanitize strip-trap + autosave normalization). Default OFF.
9208 * Saved by the mxchat_editor_assistant_enabled case in class-ajax-handler.php.
9209 * Renders on Content → Settings (moved there from Settings → Behavior, plan-f7df40).
9210 */
9211 public function mxchat_editor_assistant_toggle_callback() {
9212 $enabled = get_option('mxchat_editor_assistant_enabled', 'off');
9213 $checked = ($enabled === 'on') ? 'checked' : '';
9214
9215 echo '<label class="toggle-switch">';
9216 echo sprintf(
9217 '<input type="checkbox" id="mxchat_editor_assistant_enabled" name="mxchat_editor_assistant_enabled" value="on" %s />',
9218 esc_attr($checked)
9219 );
9220 echo '<span class="slider"></span>';
9221 echo '</label>';
9222 }
9223
9224 /**
9225 * Page-cache purge on save toggle (plan b76ed6, Support Ticket 468). Standalone
9226 * option, NOT in mxchat_options. Default ON: an absent option renders checked,
9227 * only an explicit 'off' renders unchecked (MxChat_Cache_Purge::is_enabled()).
9228 * Saved by the mxchat_cache_purge_enabled case in class-ajax-handler.php.
9229 * Renders on Settings → Optimization → Page Cache Compatibility.
9230 */
9231 public function mxchat_cache_purge_toggle_callback() {
9232 $enabled = get_option('mxchat_cache_purge_enabled', 'on');
9233 $checked = ($enabled !== 'off') ? 'checked' : '';
9234
9235 echo '<label class="toggle-switch">';
9236 echo sprintf(
9237 '<input type="checkbox" id="mxchat_cache_purge_enabled" name="mxchat_cache_purge_enabled" value="on" %s />',
9238 esc_attr($checked)
9239 );
9240 echo '<span class="slider"></span>';
9241 echo '</label>';
9242 }
9243
9244 /**
9245 * Hybrid keyword boost toggle (plan-38ffa1; rebuilt on the house toggle pattern
9246 * in plan-64d34f — the hand-rolled .mxch-toggle markup matched neither selector
9247 * the autosave JS uses to place its save confirmation, so the control looked
9248 * dead). Standalone option, NOT in mxchat_options. Default OFF. Saved by the
9249 * mxchat_hybrid_keyword_toggle case in class-ajax-handler.php, which also runs
9250 * capability detection on enable.
9251 */
9252 public function mxchat_hybrid_keyword_toggle_callback() {
9253 $enabled = get_option('mxchat_hybrid_keyword_toggle', 'off');
9254 $checked = ($enabled === 'on') ? 'checked' : '';
9255
9256 echo '<label class="toggle-switch">';
9257 echo sprintf(
9258 '<input type="checkbox" id="mxchat_hybrid_keyword_toggle" name="mxchat_hybrid_keyword_toggle" value="on" %s />',
9259 esc_attr($checked)
9260 );
9261 echo '<span class="slider"></span>';
9262 echo '</label>';
9263 }
9264
9265 /**
9266 * Smart asset loading toggle (plan-915355; rebuilt on the house toggle pattern
9267 * in plan-64d34f, same dead-save-feedback defect as the hybrid keyword toggle).
9268 * Standalone option, NOT in mxchat_options. Default OFF. Saved by the
9269 * mxchat_smart_asset_loading case in class-ajax-handler.php.
9270 */
9271 public function mxchat_smart_asset_loading_toggle_callback() {
9272 $enabled = get_option('mxchat_smart_asset_loading', 'off');
9273 $checked = ($enabled === 'on') ? 'checked' : '';
9274
9275 echo '<label class="toggle-switch">';
9276 echo sprintf(
9277 '<input type="checkbox" id="mxchat_smart_asset_loading" name="mxchat_smart_asset_loading" value="on" %s />',
9278 esc_attr($checked)
9279 );
9280 echo '<span class="slider"></span>';
9281 echo '</label>';
9282 }
9283
9284 public function mxchat_reset_chat_toggle_callback() {
9285 // Load from mxchat_options array. plan ac2e81 — default OFF (new, opt-in).
9286 $options = get_option('mxchat_options', []);
9287 $reset_chat_enabled = isset($options['reset_chat_enabled']) ? $options['reset_chat_enabled'] : 'off';
9288 $checked = ($reset_chat_enabled === 'on') ? 'checked' : '';
9289
9290 echo '<label class="toggle-switch">';
9291 echo sprintf(
9292 '<input type="checkbox" id="reset_chat_enabled" name="reset_chat_enabled" value="on" %s />',
9293 esc_attr($checked)
9294 );
9295 echo '<span class="slider"></span>';
9296 echo '</label>';
9297 }
9298
9299 public function mxchat_reset_chat_label_callback() {
9300 // Editable label for the "Start new chat" menu item. plan ac2e81.
9301 $options = get_option('mxchat_options', []);
9302 $reset_chat_label = isset($options['reset_chat_label']) ? $options['reset_chat_label'] : '';
9303
9304 printf(
9305 '<input type="text" id="reset_chat_label" name="reset_chat_label" value="%s" placeholder="%s" class="regular-text" />',
9306 esc_attr($reset_chat_label),
9307 esc_attr__('Start new chat', 'mxchat')
9308 );
9309 }
9310
9311 public function mxchat_popular_question_1_callback() {
9312 // Load the full plugin options array
9313 $all_options = get_option('mxchat_options', []);
9314
9315 // Retrieve the specific option for popular_question_1
9316 $popular_question_1 = isset($all_options['popular_question_1']) ? $all_options['popular_question_1'] : '';
9317
9318 // Render the input field
9319 printf(
9320 '<input type="text" id="popular_question_1" name="popular_question_1" value="%s" placeholder="%s" class="regular-text" />',
9321 esc_attr($popular_question_1),
9322 esc_attr__('Enter Quick Question 1', 'mxchat')
9323 );
9324 }
9325
9326
9327 public function mxchat_popular_question_2_callback() {
9328 // Load the full plugin options array
9329 $all_options = get_option('mxchat_options', []);
9330
9331 // Retrieve the specific option for popular_question_2
9332 $popular_question_2 = isset($all_options['popular_question_2']) ? $all_options['popular_question_2'] : '';
9333
9334 // Render the input field
9335 printf(
9336 '<input type="text" id="popular_question_2" name="popular_question_2" value="%s" placeholder="%s" class="regular-text" />',
9337 esc_attr($popular_question_2),
9338 esc_attr__('Enter Quick Question 2', 'mxchat')
9339 );
9340 }
9341
9342
9343 public function mxchat_popular_question_3_callback() {
9344 // Load the full plugin options array
9345 $all_options = get_option('mxchat_options', []);
9346
9347 // Retrieve the specific option for popular_question_3
9348 $popular_question_3 = isset($all_options['popular_question_3']) ? $all_options['popular_question_3'] : '';
9349
9350 // Render the input field
9351 printf(
9352 '<input type="text" id="popular_question_3" name="popular_question_3" value="%s" placeholder="%s" class="regular-text" />',
9353 esc_attr($popular_question_3),
9354 esc_attr(__('Enter Quick Question 3', 'mxchat'))
9355 );
9356 }
9357
9358 public function mxchat_additional_popular_questions_callback() {
9359 $options = get_option('mxchat_options', []);
9360 $additional_questions = isset($options['additional_popular_questions'])
9361 ? $options['additional_popular_questions']
9362 : get_option('additional_popular_questions', array());
9363
9364 echo '<div id="mxchat-additional-questions-container">';
9365 if (!empty($additional_questions)) {
9366 foreach ($additional_questions as $index => $question) {
9367 printf(
9368 '<div class="mxchat-question-row">
9369 <input type="text" name="additional_popular_questions[]"
9370 value="%s"
9371 placeholder="%s"
9372 class="regular-text mxchat-question-input"
9373 data-question-index="%d" />
9374 <button type="button" class="button mxchat-remove-question"
9375 aria-label="%s">%s</button>
9376 </div>',
9377 esc_attr($question),
9378 esc_attr(sprintf(__('Enter Additional Quick Question %d', 'mxchat'), $index + 4)),
9379 $index,
9380 esc_attr(__('Remove question', 'mxchat')),
9381 esc_html__('Remove', 'mxchat')
9382 );
9383 }
9384 } else {
9385 printf(
9386 '<div class="mxchat-question-row">
9387 <input type="text" name="additional_popular_questions[]"
9388 value=""
9389 placeholder="%s"
9390 class="regular-text mxchat-question-input"
9391 data-question-index="0" />
9392 <button type="button" class="button mxchat-remove-question"
9393 aria-label="%s">%s</button>
9394 </div>',
9395 esc_attr(__('Enter Additional Quick Question 4', 'mxchat')),
9396 esc_attr(__('Remove question', 'mxchat')),
9397 esc_html__('Remove', 'mxchat')
9398 );
9399 }
9400 echo '</div>';
9401 printf(
9402 '<button type="button" class="button mxchat-add-question" aria-label="%s">%s</button>',
9403 esc_attr(__('Add question', 'mxchat')),
9404 esc_html__('Add Question', 'mxchat')
9405 );
9406 }
9407
9408 public function mxchat_brave_api_key_callback() {
9409 $brave_api_key = isset($this->options['brave_api_key']) ? esc_attr($this->options['brave_api_key']) : '';
9410 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9411
9412 echo '<div class="api-key-wrapper">';
9413 echo sprintf(
9414 '<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" />',
9415 $brave_api_key,
9416 $nonce
9417 );
9418 echo '<button type="button" id="toggleBraveApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9419 echo '</div>';
9420 echo '<p class="description">' . __('Required for Brave Search integration. Get your API key from Brave Search API.', 'mxchat') . '</p>';
9421 }
9422
9423 public function mxchat_brave_image_count_callback() {
9424 $brave_image_count = isset($this->options['brave_image_count'])
9425 ? intval($this->options['brave_image_count'])
9426 : 4;
9427 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9428
9429 echo '<div class="mxchat-field-wrapper">';
9430 echo sprintf(
9431 '<input type="number" id="brave_image_count" name="brave_image_count"
9432 value="%d" min="1" max="6" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9433 $brave_image_count,
9434 $nonce
9435 );
9436 echo '</div>';
9437 echo '<p class="description">' . __('Select the number of images to return (1-6).', 'mxchat') . '</p>';
9438 }
9439
9440 public function mxchat_brave_safe_search_callback() {
9441 $brave_safe_search = isset($this->options['brave_safe_search'])
9442 ? esc_attr($this->options['brave_safe_search'])
9443 : 'strict';
9444 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9445
9446 echo '<div class="mxchat-field-wrapper">';
9447 echo '<select id="brave_safe_search" name="brave_safe_search" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
9448 echo sprintf(
9449 '<option value="strict" %s>%s</option>',
9450 selected($brave_safe_search, 'strict', false),
9451 __('Strict', 'mxchat')
9452 );
9453 echo sprintf(
9454 '<option value="off" %s>%s</option>',
9455 selected($brave_safe_search, 'off', false),
9456 __('Off', 'mxchat')
9457 );
9458 echo '</select>';
9459 echo '</div>';
9460 echo '<p class="description">' .
9461 esc_html__('Set the Safe Search level for image searches. Brave Search only supports "Strict" and "Off" options.', 'mxchat') .
9462 '</p>';
9463 }
9464
9465 public function mxchat_brave_news_count_callback() {
9466 $brave_news_count = isset($this->options['brave_news_count'])
9467 ? intval($this->options['brave_news_count'])
9468 : 3;
9469 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9470
9471 echo '<div class="mxchat-field-wrapper">';
9472 echo sprintf(
9473 '<input type="number" id="brave_news_count" name="brave_news_count"
9474 value="%d" min="1" max="10" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9475 $brave_news_count,
9476 $nonce
9477 );
9478 echo '</div>';
9479 echo '<p class="description">' . esc_html__('Select the number of news articles to retrieve (1-10).', 'mxchat') . '</p>';
9480 }
9481
9482 public function mxchat_brave_country_callback() {
9483 $brave_country = isset($this->options['brave_country'])
9484 ? esc_attr($this->options['brave_country'])
9485 : 'us';
9486 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9487
9488 echo '<div class="mxchat-field-wrapper">';
9489 echo sprintf(
9490 '<input type="text" id="brave_country" name="brave_country"
9491 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9492 $brave_country,
9493 $nonce
9494 );
9495 echo '</div>';
9496 echo '<p class="description">' . esc_html__('Enter the country code (e.g., "us" for United States).', 'mxchat') . '</p>';
9497 }
9498
9499 public function mxchat_brave_language_callback() {
9500 $brave_language = isset($this->options['brave_language'])
9501 ? esc_attr($this->options['brave_language'])
9502 : 'en';
9503 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9504
9505 echo '<div class="mxchat-field-wrapper">';
9506 echo sprintf(
9507 '<input type="text" id="brave_language" name="brave_language"
9508 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9509 $brave_language,
9510 $nonce
9511 );
9512 echo '</div>';
9513 echo '<p class="description">' . esc_html__('Enter the language code (e.g., "en" for English).', 'mxchat') . '</p>';
9514 }
9515
9516
9517
9518
9519
9520 // Section Callback
9521 public function mxchat_pdf_intent_section_callback() {
9522 echo '<p>' . esc_html__('Configure the intent settings for the Chat with PDF feature.', 'mxchat') . '</p>';
9523 }
9524
9525 public function mxchat_chat_toolbar_toggle_callback() {
9526 // Get chat toolbar toggle value with fallback
9527 $chat_toolbar_toggle = isset($this->options['chat_toolbar_toggle']) ? $this->options['chat_toolbar_toggle'] : 'off';
9528 $checked = ($chat_toolbar_toggle === 'on') ? 'checked' : '';
9529
9530 // Output the toggle switch
9531 echo '<label class="toggle-switch">';
9532 echo sprintf(
9533 '<input type="checkbox" id="chat_toolbar_toggle" name="chat_toolbar_toggle" value="on" %s />',
9534 esc_attr($checked)
9535 );
9536 echo '<span class="slider"></span>';
9537 echo '</label>';
9538
9539 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>';
9540 }
9541
9542 /**
9543 * Callback for PDF upload button toggle setting
9544 */
9545 public function mxchat_show_pdf_upload_button_callback() {
9546 // Get toggle value with fallback
9547 $show_pdf_button = isset($this->options['show_pdf_upload_button']) ? $this->options['show_pdf_upload_button'] : 'on';
9548 $checked = ($show_pdf_button === 'on') ? 'checked' : '';
9549
9550 // Output the toggle switch
9551 echo '<label class="toggle-switch">';
9552 echo sprintf(
9553 '<input type="checkbox" id="show_pdf_upload_button" name="show_pdf_upload_button" value="on" %s />',
9554 esc_attr($checked)
9555 );
9556 echo '<span class="slider"></span>';
9557 echo '</label>';
9558
9559 echo '<p class="description">' . esc_html__('Enable to show the PDF upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
9560 }
9561
9562 /**
9563 * Callback for Word upload button toggle setting
9564 */
9565 public function mxchat_show_word_upload_button_callback() {
9566 // Get toggle value with fallback
9567 $show_word_button = isset($this->options['show_word_upload_button']) ? $this->options['show_word_upload_button'] : 'on';
9568 $checked = ($show_word_button === 'on') ? 'checked' : '';
9569
9570 // Output the toggle switch
9571 echo '<label class="toggle-switch">';
9572 echo sprintf(
9573 '<input type="checkbox" id="show_word_upload_button" name="show_word_upload_button" value="on" %s />',
9574 esc_attr($checked)
9575 );
9576 echo '<span class="slider"></span>';
9577 echo '</label>';
9578
9579 echo '<p class="description">' . esc_html__('Enable to show the Word document upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
9580 }
9581
9582 public function mxchat_pdf_intent_trigger_text_callback() {
9583 $default_text = __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
9584
9585 echo sprintf(
9586 '<textarea id="pdf_intent_trigger_text"
9587 name="pdf_intent_trigger_text"
9588 rows="3"
9589 cols="50"
9590 placeholder="%s">%s</textarea>',
9591 esc_attr__('Enter trigger text', 'mxchat'),
9592 isset($this->options['pdf_intent_trigger_text'])
9593 ? esc_textarea($this->options['pdf_intent_trigger_text'])
9594 : esc_textarea($default_text)
9595 );
9596 echo '<p class="description">' . esc_html__('Text displayed when the intent is triggered.', 'mxchat') . '</p>';
9597 }
9598
9599 public function mxchat_pdf_intent_success_text_callback() {
9600 $default_text = __("I've processed the PDF. What questions do you have about it?", 'mxchat');
9601
9602 echo sprintf(
9603 '<textarea id="pdf_intent_success_text"
9604 name="pdf_intent_success_text"
9605 rows="3"
9606 cols="50"
9607 placeholder="%s">%s</textarea>',
9608 esc_attr__('Enter success text', 'mxchat'),
9609 isset($this->options['pdf_intent_success_text'])
9610 ? esc_textarea($this->options['pdf_intent_success_text'])
9611 : esc_textarea($default_text)
9612 );
9613 echo '<p class="description">' . esc_html__('Text displayed when the intent is successful.', 'mxchat') . '</p>';
9614 }
9615
9616 public function mxchat_pdf_intent_error_text_callback() {
9617 $default_text = __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
9618
9619 echo sprintf(
9620 '<textarea id="pdf_intent_error_text"
9621 name="pdf_intent_error_text"
9622 rows="3"
9623 cols="50"
9624 placeholder="%s">%s</textarea>',
9625 esc_attr__('Enter error text', 'mxchat'),
9626 isset($this->options['pdf_intent_error_text'])
9627 ? esc_textarea($this->options['pdf_intent_error_text'])
9628 : esc_textarea($default_text)
9629 );
9630 echo '<p class="description">' . esc_html__('Text displayed when an error occurs during the intent.', 'mxchat') . '</p>';
9631 }
9632
9633 public function mxchat_pdf_max_pages_callback() {
9634 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
9635
9636 echo sprintf(
9637 '<input type="range"
9638 id="pdf_max_pages"
9639 name="pdf_max_pages"
9640 min="1"
9641 max="69"
9642 value="%d"
9643 class="range-slider" />',
9644 esc_attr($max_pages)
9645 );
9646 echo '<span id="pdf_max_pages_output">' . esc_html($max_pages) . '</span>';
9647 echo '<p class="description">' . esc_html__('Set the maximum number of document pages users can upload for processing. (1-69 pages)', 'mxchat') . '</p>';
9648 }
9649
9650 public function mxchat_live_agent_status_callback() {
9651 // Always get fresh options instead of using cached $this->options
9652 $fresh_options = get_option('mxchat_options');
9653 $status = isset($fresh_options['live_agent_status']) ? $fresh_options['live_agent_status'] : 'off';
9654
9655 echo '<label class="toggle-switch">';
9656 echo sprintf(
9657 '<input type="checkbox" id="live_agent_status" name="live_agent_status" value="on" %s />',
9658 checked($status, 'on', false)
9659 );
9660 echo '<span class="slider"></span>';
9661 echo '</label>';
9662 echo '<label for="live_agent_status" class="mxchat-status-label">';
9663 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9664 echo '</label>';
9665 }
9666
9667 /**
9668 * Availability-schedule editor (plans 8ccaa2 + 99d7a4).
9669 *
9670 * ONE callback, parameterized by channel ('slack' | 'telegram' via the
9671 * add_settings_field $args) — the markup and CSS are shared, only the bound
9672 * option and the helper text differ. Each channel's editor renders under its
9673 * own Integrations tab and governs ONLY that channel's handoff. While the
9674 * master toggle is off the day grid is inert and handoff availability stays
9675 * governed solely by that channel's manual status toggle.
9676 *
9677 * The day inputs carry NO name attribute and are marked .mxchat-la-field so the
9678 * generic autosave skips them; the editor JS folds them into the channel's
9679 * hidden live_agent_schedule_<channel> input and fires one change, reusing the
9680 * existing autosave transport rather than growing a second one. All hooks the
9681 * JS needs are CLASSES scoped inside .mxchat-la-schedule, never ids — the
9682 * markup exists twice on the page.
9683 */
9684 public function mxchat_live_agent_schedule_callback($args = array()) {
9685 if (!class_exists('MxChat_Live_Agent_Schedule')) {
9686 return;
9687 }
9688 $channel = (isset($args['channel']) && in_array($args['channel'], MxChat_Live_Agent_Schedule::channels(), true))
9689 ? $args['channel']
9690 : 'slack';
9691 $field = 'live_agent_schedule_' . $channel;
9692 $sched = MxChat_Live_Agent_Schedule::get($channel);
9693 $labels = MxChat_Live_Agent_Schedule::day_labels();
9694 $tz = MxChat_Live_Agent_Schedule::timezone_label();
9695 $enabled = !empty($sched['enabled']);
9696 $channel_labels = array(
9697 'slack' => __('Slack', 'mxchat'),
9698 'telegram' => __('Telegram', 'mxchat'),
9699 'webhook' => __('Webhook', 'mxchat'),
9700 );
9701 $channel_label = $channel_labels[$channel];
9702 ?>
9703 <div class="mxchat-la-schedule<?php echo $enabled ? ' is-active' : ''; ?>"
9704 id="mxchat-la-schedule-<?php echo esc_attr($channel); ?>"
9705 data-channel="<?php echo esc_attr($channel); ?>">
9706 <label class="toggle-switch">
9707 <input type="checkbox" id="<?php echo esc_attr($field); ?>_enabled"
9708 class="mxchat-la-field mxchat-la-enabled" <?php checked($enabled); ?> />
9709 <span class="slider"></span>
9710 </label>
9711 <label for="<?php echo esc_attr($field); ?>_enabled" class="mxchat-status-label">
9712 <span class="status-text mxchat-la-status-text">
9713 <?php echo $enabled
9714 ? esc_html__('Scheduled hours', 'mxchat')
9715 : esc_html__('Always available', 'mxchat'); ?>
9716 </span>
9717 </label>
9718
9719 <div class="mxchat-la-days" aria-hidden="<?php echo $enabled ? 'false' : 'true'; ?>">
9720 <?php foreach ($labels as $n => $label) :
9721 $day = $sched['days'][$n];
9722 $on = !empty($day['enabled']);
9723 ?>
9724 <div class="mxchat-la-day<?php echo $on ? ' is-on' : ''; ?>" data-day="<?php echo esc_attr($n); ?>">
9725 <label class="mxchat-la-day-label">
9726 <input type="checkbox" class="mxchat-la-field mxchat-la-day-enabled"
9727 data-day="<?php echo esc_attr($n); ?>" <?php checked($on); ?> />
9728 <span class="mxchat-la-day-name"><?php echo esc_html($label); ?></span>
9729 </label>
9730 <div class="mxchat-la-times">
9731 <input type="time" class="mxchat-la-field mxchat-la-start"
9732 data-day="<?php echo esc_attr($n); ?>"
9733 value="<?php echo esc_attr($day['start']); ?>"
9734 aria-label="<?php echo esc_attr(sprintf(__('%s start time', 'mxchat'), $label)); ?>" />
9735 <span class="mxchat-la-dash">&ndash;</span>
9736 <input type="time" class="mxchat-la-field mxchat-la-end"
9737 data-day="<?php echo esc_attr($n); ?>"
9738 value="<?php echo esc_attr($day['end']); ?>"
9739 aria-label="<?php echo esc_attr(sprintf(__('%s end time', 'mxchat'), $label)); ?>" />
9740 </div>
9741 </div>
9742 <?php endforeach; ?>
9743 </div>
9744
9745 <input type="hidden" name="<?php echo esc_attr($field); ?>" id="<?php echo esc_attr($field); ?>"
9746 class="mxchat-la-hidden" value="<?php echo esc_attr(wp_json_encode($sched)); ?>" />
9747 </div>
9748 <p class="description">
9749 <?php printf(
9750 /* translators: 1: the handoff channel, e.g. Slack or Telegram; 2: the site's timezone, e.g. America/New_York */
9751 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'),
9752 '<strong>' . esc_html($channel_label) . '</strong>',
9753 '<strong>' . esc_html($tz) . '</strong>'
9754 ); ?>
9755 </p>
9756 <?php
9757 }
9758
9759 public function mxchat_live_agent_away_message_callback() {
9760 $message = isset($this->options['live_agent_away_message'])
9761 ? $this->options['live_agent_away_message']
9762 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9763
9764 printf(
9765 '<textarea id="live_agent_away_message" name="live_agent_away_message" rows="3" cols="50">%s</textarea>',
9766 esc_textarea($message)
9767 );
9768 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9769 }
9770
9771 public function mxchat_live_agent_notification_message_callback() {
9772 $message = isset($this->options['live_agent_notification_message'])
9773 ? $this->options['live_agent_notification_message']
9774 : __('Live agent has been notified.', 'mxchat');
9775
9776 printf(
9777 '<textarea id="live_agent_notification_message" name="live_agent_notification_message" rows="3" cols="50">%s</textarea>',
9778 esc_textarea($message)
9779 );
9780 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9781 }
9782
9783 public function mxchat_live_agent_webhook_url_callback() {
9784 $webhook_url = isset($this->options['live_agent_webhook_url'])
9785 ? esc_url($this->options['live_agent_webhook_url'])
9786 : esc_url(get_option('live_agent_webhook_url', ''));
9787
9788 printf(
9789 '<input type="password" id="live_agent_webhook_url" name="live_agent_webhook_url" value="%s" class="regular-text" />',
9790 $webhook_url
9791 );
9792 echo '<button type="button" id="toggleWebhookUrlVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9793 echo '<p class="description">' . esc_html__('Enter your Slack webhook URL for live agent notifications.', 'mxchat') . '</p>';
9794 }
9795
9796 public function mxchat_live_agent_secret_key_callback() {
9797 printf(
9798 '<input type="password" id="live_agent_secret_key" name="live_agent_secret_key" value="%s" class="regular-text" />',
9799 isset($this->options['live_agent_secret_key']) ? esc_attr($this->options['live_agent_secret_key']) : ''
9800 );
9801 echo '<button type="button" id="toggleSecretKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9802 echo '<p class="description">' . esc_html__('Secret key for validating Slack requests. Keep this secure.', 'mxchat') . '</p>';
9803 }
9804
9805 public function mxchat_live_agent_bot_token_callback() {
9806 printf(
9807 '<input type="password" id="live_agent_bot_token" name="live_agent_bot_token" value="%s" class="regular-text" />',
9808 isset($this->options['live_agent_bot_token']) ? esc_attr($this->options['live_agent_bot_token']) : ''
9809 );
9810 echo '<button type="button" id="toggleBotTokenVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9811 echo '<p class="description">' . esc_html__('Your Slack Bot OAuth Token (starts with xoxb-). Keep this secure.', 'mxchat') . '</p>';
9812
9813 // Surface the last live-agent post Slack refused (plan 2a4ab7). Slack
9814 // answers a refused post with HTTP 200, so before this existed a deleted
9815 // or archived channel or a revoked token failed with no trace anywhere.
9816 // The next successful post clears it.
9817 $slack_error = get_option('mxchat_slack_last_error');
9818 if (is_array($slack_error) && !empty($slack_error['error'])) {
9819 $slack_error_time = !empty($slack_error['time'])
9820 ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $slack_error['time'])
9821 : __('unknown time', 'mxchat');
9822 if (!empty($slack_error['channel'])) {
9823 $slack_error_text = sprintf(
9824 /* translators: 1: Slack API error code, 2: Slack channel ID, 3: local date and time of the failure */
9825 __('Slack said "%1$s" for channel %2$s (%3$s). The visitor was told the message could not be delivered. Check that the channel still exists and is not archived, that the bot is in it, and that the token above is still valid.', 'mxchat'),
9826 $slack_error['error'],
9827 $slack_error['channel'],
9828 $slack_error_time
9829 );
9830 } else {
9831 $slack_error_text = sprintf(
9832 /* translators: 1: Slack API error code, 2: local date and time of the failure */
9833 __('Slack said "%1$s" (%2$s). The visitor was told the message could not be delivered. Check that the token above is still valid.', 'mxchat'),
9834 $slack_error['error'],
9835 $slack_error_time
9836 );
9837 }
9838 echo '<p class="description"><strong>' . esc_html__('⚠ Last delivery error', 'mxchat') . '</strong> — ' . esc_html($slack_error_text) . '</p>';
9839 }
9840 }
9841
9842 public function mxchat_live_agent_user_ids_callback() {
9843 $user_ids = isset($this->options['live_agent_user_ids'])
9844 ? esc_textarea($this->options['live_agent_user_ids'])
9845 : '';
9846
9847 printf(
9848 '<textarea id="live_agent_user_ids" name="live_agent_user_ids" rows="4" class="large-text">%s</textarea>',
9849 $user_ids
9850 );
9851 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>';
9852 }
9853
9854 public function mxchat_live_agent_shared_channel_callback() {
9855 $value = isset($this->options['live_agent_shared_channel']) ? $this->options['live_agent_shared_channel'] : '';
9856 printf(
9857 '<input type="text" id="live_agent_shared_channel" name="live_agent_shared_channel" value="%s" class="regular-text" placeholder="#support or C0123456789" />',
9858 esc_attr($value)
9859 );
9860 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>';
9861
9862 // Surface the last failed handoff so a wrong name / missing invite is
9863 // visible right where it gets fixed. A successful handoff clears this.
9864 $shared_error = get_option('mxchat_slack_shared_channel_error');
9865 if (!empty($shared_error['error']) && trim((string) $value) !== '' && ($shared_error['configured'] ?? '') === trim((string) $value)) {
9866 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this channel', 'mxchat') . '</strong> — '
9867 . esc_html(sprintf(
9868 /* translators: %s: Slack API error code */
9869 __('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'),
9870 $shared_error['error']
9871 )) . '</p>';
9872 }
9873
9874 // Private-channel warning (plan 1a2666), recorded by the privacy probe at
9875 // configuration time or when the channel id first resolves. Posting INTO
9876 // a private channel works once the bot is a member, so everything looks
9877 // configured — but Slack delivers inbound replies from private channels
9878 // as message.groups events, which the documented app setup never
9879 // subscribes to, and agent replies silently never reach the visitor.
9880 $shared_privacy = get_option('mxchat_slack_shared_channel_privacy');
9881 if (!empty($shared_privacy['is_private']) && trim((string) $value) !== '' && ($shared_privacy['configured'] ?? '') === trim((string) $value)) {
9882 echo '<p class="description"><strong>' . esc_html__('⚠ This is a private channel', 'mxchat') . '</strong> — '
9883 . 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>';
9884 }
9885 }
9886
9887 public function mxchat_live_agent_archive_on_end_callback() {
9888 $value = isset($this->options['live_agent_archive_on_end_toggle']) ? $this->options['live_agent_archive_on_end_toggle'] : 'off';
9889 printf(
9890 '<input type="checkbox" id="live_agent_archive_on_end_toggle" name="live_agent_archive_on_end_toggle" value="on" %s />',
9891 checked('on', $value, false)
9892 );
9893 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>';
9894 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>';
9895 }
9896
9897 /**
9898 * Telegram Integration Callbacks
9899 */
9900 public function mxchat_telegram_section_callback() {
9901 echo '<p>' . esc_html__('Configure Telegram integration for live agent support.', 'mxchat') . '</p>';
9902 }
9903
9904 public function mxchat_telegram_status_callback() {
9905 $fresh_options = get_option('mxchat_options');
9906 $status = isset($fresh_options['telegram_status']) ? $fresh_options['telegram_status'] : 'off';
9907
9908 echo '<label class="toggle-switch">';
9909 echo sprintf(
9910 '<input type="checkbox" id="telegram_status" name="telegram_status" value="on" %s />',
9911 checked($status, 'on', false)
9912 );
9913 echo '<span class="slider"></span>';
9914 echo '</label>';
9915 echo '<label for="telegram_status" class="mxchat-status-label">';
9916 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9917 echo '</label>';
9918 }
9919
9920 public function mxchat_telegram_notification_message_callback() {
9921 $message = isset($this->options['telegram_notification_message'])
9922 ? $this->options['telegram_notification_message']
9923 : __("I've notified a support agent. Please allow a moment for them to respond.", 'mxchat');
9924
9925 printf(
9926 '<textarea id="telegram_notification_message" name="telegram_notification_message" rows="3" cols="50">%s</textarea>',
9927 esc_textarea($message)
9928 );
9929 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9930 }
9931
9932 public function mxchat_telegram_away_message_callback() {
9933 $message = isset($this->options['telegram_away_message'])
9934 ? $this->options['telegram_away_message']
9935 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9936
9937 printf(
9938 '<textarea id="telegram_away_message" name="telegram_away_message" rows="3" cols="50">%s</textarea>',
9939 esc_textarea($message)
9940 );
9941 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9942 }
9943
9944 public function mxchat_telegram_bot_token_callback() {
9945 printf(
9946 '<input type="password" id="telegram_bot_token" name="telegram_bot_token" value="%s" class="regular-text" />',
9947 isset($this->options['telegram_bot_token']) ? esc_attr($this->options['telegram_bot_token']) : ''
9948 );
9949 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>';
9950 echo '<p class="description">' . esc_html__('Your Telegram Bot Token from @BotFather (e.g., 123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ).', 'mxchat') . '</p>';
9951 }
9952
9953 public function mxchat_telegram_group_id_callback() {
9954 printf(
9955 '<input type="text" id="telegram_group_id" name="telegram_group_id" value="%s" class="regular-text" />',
9956 isset($this->options['telegram_group_id']) ? esc_attr($this->options['telegram_group_id']) : ''
9957 );
9958 echo '<p class="description">' . esc_html__('Your Telegram supergroup ID (starts with -100). The group must have forum topics enabled.', 'mxchat') . '</p>';
9959
9960 // Surface the last failed Telegram API call right where it gets fixed
9961 // (plan 1aeee3). Before this existed every Telegram response was
9962 // discarded — a wrong group id, a closed topic, or an oversized handoff
9963 // all failed with no trace anywhere. Kept until the next failure
9964 // overwrites it; the timestamp shows how stale it is.
9965 $tg_error = get_option('mxchat_telegram_last_error');
9966 if (!empty($tg_error['error'])) {
9967 echo '<p class="description"><strong>' . esc_html__('⚠ Last Telegram API failure', 'mxchat') . '</strong> — '
9968 . esc_html(sprintf(
9969 /* translators: 1: Telegram API method name, 2: Telegram's error description, 3: local date and time of the failure */
9970 __('%1$s failed with "%2$s" (%3$s). If live agent chat is working now, this may be from an earlier misconfiguration.', 'mxchat'),
9971 $tg_error['method'] ?? 'sendMessage',
9972 $tg_error['error'],
9973 !empty($tg_error['time']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $tg_error['time']) : __('unknown time', 'mxchat')
9974 )) . '</p>';
9975 }
9976
9977 // The inbound counterpart of the line above (plan 30398e): outbound tells you
9978 // MxChat could not reach Telegram, this one tells you Telegram reached MxChat
9979 // and was turned away. That failure previously left nothing behind but a
9980 // single error_log() line, so a Cloudflare-fronted install with no secret set
9981 // dropped every agent reply with no admin-side trace at all. Cleared
9982 // automatically by the first accepted request.
9983 $tg_rejects = get_option('mxchat_telegram_webhook_rejects');
9984 if (is_array($tg_rejects) && !empty($tg_rejects['count'])) {
9985 $reject_count = (int) $tg_rejects['count'];
9986 echo '<p class="description" style="color:#b32d2e;"><strong>' . esc_html__('⚠ Incoming Telegram webhook requests are being rejected', 'mxchat') . '</strong> — '
9987 . esc_html(sprintf(
9988 /* translators: 1: number of rejected requests, 2: local date and time of the most recent rejection */
9989 _n(
9990 '%1$s request rejected, most recently %2$s. Agent replies are not reaching visitors.',
9991 '%1$s requests rejected, most recently %2$s. Agent replies are not reaching visitors.',
9992 $reject_count,
9993 'mxchat'
9994 ),
9995 number_format_i18n($reject_count),
9996 !empty($tg_rejects['last_ts']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $tg_rejects['last_ts']) : __('unknown time', 'mxchat')
9997 ))
9998 . ' ' . esc_html($this->mxchat_telegram_reject_advice($tg_rejects['last_reason'] ?? ''))
9999 . '</p>';
10000 }
10001 }
10002
10003 public function mxchat_telegram_webhook_secret_callback() {
10004 $secret = isset($this->options['telegram_webhook_secret']) ? $this->options['telegram_webhook_secret'] : '';
10005
10006 // Auto-generate secret if empty
10007 if (empty($secret)) {
10008 $secret = wp_generate_password(64, false, false);
10009 $options = get_option('mxchat_options', []);
10010 $options['telegram_webhook_secret'] = $secret;
10011 update_option('mxchat_options', $options);
10012 $this->options['telegram_webhook_secret'] = $secret;
10013 }
10014
10015 printf(
10016 '<input type="password" id="telegram_webhook_secret" name="telegram_webhook_secret" value="%s" class="regular-text" />',
10017 esc_attr($secret)
10018 );
10019 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>';
10020 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>';
10021 }
10022
10023 /**
10024 * Webhook Handoff Callbacks (plan d88e22)
10025 */
10026 public function mxchat_webhook_handoff_section_callback() {
10027 echo '<p>' . esc_html__('Send live-agent handoffs to any URL you choose — your helpdesk, an automation flow, a CRM.', 'mxchat') . '</p>';
10028 }
10029
10030 public function mxchat_webhook_handoff_status_callback() {
10031 $fresh_options = get_option('mxchat_options');
10032 $status = isset($fresh_options['webhook_handoff_status']) ? $fresh_options['webhook_handoff_status'] : 'off';
10033
10034 echo '<label class="toggle-switch">';
10035 echo sprintf(
10036 '<input type="checkbox" id="webhook_handoff_status" name="webhook_handoff_status" value="on" %s />',
10037 checked($status, 'on', false)
10038 );
10039 echo '<span class="slider"></span>';
10040 echo '</label>';
10041 echo '<label for="webhook_handoff_status" class="mxchat-status-label">';
10042 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
10043 echo '</label>';
10044 }
10045
10046 public function mxchat_webhook_handoff_notification_message_callback() {
10047 $message = isset($this->options['webhook_handoff_notification_message'])
10048 ? $this->options['webhook_handoff_notification_message']
10049 : __("I've notified our support team — they'll follow up with you soon. Meanwhile, I'm happy to keep helping.", 'mxchat');
10050
10051 printf(
10052 '<textarea id="webhook_handoff_notification_message" name="webhook_handoff_notification_message" rows="3" cols="50">%s</textarea>',
10053 esc_textarea($message)
10054 );
10055 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>';
10056 }
10057
10058 public function mxchat_webhook_handoff_away_message_callback() {
10059 $message = isset($this->options['webhook_handoff_away_message'])
10060 ? $this->options['webhook_handoff_away_message']
10061 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
10062
10063 printf(
10064 '<textarea id="webhook_handoff_away_message" name="webhook_handoff_away_message" rows="3" cols="50">%s</textarea>',
10065 esc_textarea($message)
10066 );
10067 echo '<p class="description">' . esc_html__('Message shown when this destination is offline or outside its scheduled hours.', 'mxchat') . '</p>';
10068 }
10069
10070 public function mxchat_webhook_handoff_url_callback() {
10071 $value = isset($this->options['webhook_handoff_url']) ? $this->options['webhook_handoff_url'] : '';
10072 printf(
10073 '<input type="url" id="webhook_handoff_url" name="webhook_handoff_url" value="%s" class="regular-text" placeholder="https://example.com/hooks/mxchat" />',
10074 esc_attr($value)
10075 );
10076 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>';
10077
10078 // Surface the last failed delivery right where it gets fixed — a handoff
10079 // that silently 404s is worse than no handoff. A successful one clears this.
10080 $webhook_error = get_option('mxchat_webhook_handoff_error');
10081 if (!empty($webhook_error['error']) && trim((string) $value) !== '' && ($webhook_error['configured'] ?? '') === trim((string) $value)) {
10082 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this URL', 'mxchat') . '</strong> — '
10083 . esc_html(sprintf(
10084 /* translators: %s: the delivery failure, e.g. an HTTP status or a transport error */
10085 __('the request failed with "%s". Until this is fixed, the chatbot answers these conversations itself instead of telling visitors a human was notified.', 'mxchat'),
10086 $webhook_error['error']
10087 )) . '</p>';
10088 }
10089 }
10090
10091 public function mxchat_webhook_handoff_secret_callback() {
10092 printf(
10093 '<input type="password" id="webhook_handoff_secret" name="webhook_handoff_secret" value="%s" class="regular-text" />',
10094 isset($this->options['webhook_handoff_secret']) ? esc_attr($this->options['webhook_handoff_secret']) : ''
10095 );
10096 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>';
10097 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>';
10098 }
10099
10100 public function mxchat_similarity_threshold_callback() {
10101 // Load from mxchat_options array
10102 $options = get_option('mxchat_options', []);
10103
10104 // Get value from options array with default of 35
10105 $threshold = isset($options['similarity_threshold']) ? $options['similarity_threshold'] : 35;
10106
10107 echo '<div class="slider-container">';
10108 echo sprintf(
10109 '<input type="range"
10110 id="similarity_threshold"
10111 name="similarity_threshold"
10112 min="20"
10113 max="85"
10114 step="1"
10115 value="%s"
10116 class="range-slider" />',
10117 esc_attr($threshold)
10118 );
10119 echo sprintf(
10120 '<span id="threshold_value" class="range-value">%s</span>',
10121 esc_html($threshold)
10122 );
10123 echo '</div>';
10124 }
10125
10126 public function mxchat_max_input_length_callback() {
10127 // Load from mxchat_options array (plan a3fae2 part C).
10128 $options = get_option('mxchat_options', []);
10129
10130 // 0 = unlimited (default — preserves current behavior, no cap).
10131 $max_input_length = isset($options['max_input_length']) ? intval($options['max_input_length']) : 0;
10132
10133 echo sprintf(
10134 '<input type="number"
10135 id="max_input_length"
10136 name="max_input_length"
10137 min="0"
10138 max="100000"
10139 step="1"
10140 value="%s"
10141 placeholder="0"
10142 class="mxch-input mxch-input-sm" />',
10143 esc_attr($max_input_length)
10144 );
10145 }
10146
10147 public function mxchat_rag_sources_limit_callback() {
10148 // Load from mxchat_options array
10149 $options = get_option('mxchat_options', []);
10150
10151 // Get value from options array with default of 3
10152 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
10153
10154 echo '<div class="slider-container">';
10155 echo sprintf(
10156 '<input type="range"
10157 id="rag_sources_limit"
10158 name="rag_sources_limit"
10159 min="3"
10160 max="10"
10161 step="1"
10162 value="%s"
10163 class="range-slider" />',
10164 esc_attr($rag_sources_limit)
10165 );
10166 echo sprintf(
10167 '<span id="rag_sources_limit_value" class="range-value">%s</span>',
10168 esc_html($rag_sources_limit)
10169 );
10170 echo '</div>';
10171 }
10172
10173 public function mxchat_rag_chunks_limit_callback() {
10174 // Load from mxchat_options array
10175 $options = get_option('mxchat_options', []);
10176
10177 // Get value from options array with default of 15
10178 $rag_chunks_limit = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
10179
10180 echo '<div class="slider-container">';
10181 echo sprintf(
10182 '<input type="range"
10183 id="rag_chunks_limit"
10184 name="rag_chunks_limit"
10185 min="8"
10186 max="20"
10187 step="1"
10188 value="%s"
10189 class="range-slider" />',
10190 esc_attr($rag_chunks_limit)
10191 );
10192 echo sprintf(
10193 '<span id="rag_chunks_limit_value" class="range-value">%s</span>',
10194 esc_html($rag_chunks_limit)
10195 );
10196 echo '</div>';
10197 }
10198
10199 /**
10200 * Add body class on the Onboarding wizard page so CSS-only chrome surgery
10201 * (collapsing the WP admin sidebar) only applies on this page.
10202 * Plan: plan-mxchat-20260527-905439.
10203 */
10204 public function mxchat_add_onboarding_body_class($classes) {
10205 if (isset($_GET['page']) && $_GET['page'] === 'mxchat-onboarding') {
10206 $classes .= ' mxchat-onboarding-focused';
10207 }
10208 return $classes;
10209 }
10210
10211 public function mxchat_enqueue_admin_assets($hook_suffix = '') {
10212 // Authorization gate (plan-mxchat-20260731-c63fb6). Previously the ONLY
10213 // guard here was the strpos() on $_GET['page'] below — which is
10214 // attacker-controlled — so ANY logged-in user (Subscriber included) could
10215 // load a page they are legitimately allowed to see, e.g.
10216 // /wp-admin/profile.php?page=mxchat, and receive every nonce this method
10217 // localizes. Three of the handlers behind those nonces verified the nonce
10218 // but checked no capability. Every MxChat menu page is registered
10219 // 'manage_options' (see mxchat_add_admin_menu), so this is a no-op for
10220 // legitimate users.
10221 if (!current_user_can('manage_options')) {
10222 return;
10223 }
10224
10225 // Get plugin version
10226 $version = MXCHAT_VERSION;
10227
10228 // Use file modification time for development (remove in production)
10229 if (defined('WP_DEBUG') && WP_DEBUG) {
10230 $version = filemtime(plugin_dir_path(__FILE__) . '../mxchat-basic.php');
10231 }
10232
10233 $current_page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
10234 $plugin_url = plugin_dir_url(__FILE__) . '../';
10235
10236 // Only load on MxChat pages. Prefer the server-derived $hook_suffix that
10237 // admin_enqueue_scripts passes us — unlike $_GET['page'] it cannot be
10238 // forged onto a screen MxChat does not own. Falls back to the legacy
10239 // $_GET check only when the hook is unavailable (direct/legacy callers).
10240 if ($hook_suffix !== '') {
10241 if (strpos($hook_suffix, 'mxchat') === false) {
10242 return;
10243 }
10244 } elseif (strpos($current_page, 'mxchat') === false) {
10245 return;
10246 }
10247
10248 // Always load these on all MxChat pages
10249 $this->enqueue_core_admin_assets($plugin_url, $version);
10250 $this->enqueue_page_specific_assets($current_page, $plugin_url, $version);
10251 $this->localize_admin_scripts($current_page);
10252 }
10253 private function enqueue_core_admin_assets($plugin_url, $version) {
10254 // Core admin styles
10255 wp_enqueue_style('mxchat-admin-css', $plugin_url . 'css/admin-style.css', array(), $version);
10256 wp_enqueue_style('mxchat-knowledge-css', $plugin_url . 'css/knowledge-style.css', array(), $version);
10257
10258 // New sidebar navigation styles (for main settings page)
10259 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10260
10261 // Core admin scripts
10262 wp_enqueue_script('mxchat-admin-js', $plugin_url . 'js/mxchat-admin.js', array('jquery'), $version, true);
10263 }
10264 private function enqueue_page_specific_assets($current_page, $plugin_url, $version) {
10265 switch ($current_page) {
10266 case 'mxchat-prompts':
10267 // Shared shell JS: tab switching, deep-linking and the mobile menu
10268 // for this page's flat nav (inline copies removed by plan c192a4).
10269 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10270 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10271 'copied' => __('Copied', 'mxchat'),
10272 ));
10273 // Knowledge processing page assets
10274 wp_enqueue_style('mxchat-content-selector-css', $plugin_url . 'css/content-selector.css', array(), $version);
10275 wp_enqueue_script('mxchat-content-selector-js', $plugin_url . 'js/content-selector.js', array('jquery'), $version, true);
10276 // Add the knowledge processing script (common script needed for WordPress dismiss functionality)
10277 wp_enqueue_script('mxchat-knowledge-processing', $plugin_url . 'js/knowledge-processing.js', array('jquery', 'common'), $version, true);
10278 // "Re-import affected entries" card (plan 89739b)
10279 wp_enqueue_script('mxchat-kb-reimport', $plugin_url . 'js/knowledge-reimport.js', array(), $version, true);
10280 wp_localize_script('mxchat-kb-reimport', 'mxchatKbReimport', array(
10281 'ajaxUrl' => admin_url('admin-ajax.php'),
10282 'requestFailed' => __('The request did not go through. Nothing is lost: try again.', 'mxchat'),
10283 /* translators: %s: number of entries checked so far */
10284 'checking' => __('Checking, %s entries so far', 'mxchat'),
10285 /* translators: 1: entries rewritten so far, 2: entries to rewrite */
10286 'rewriting' => __('Rewriting, %1$s of %2$s', 'mxchat'),
10287 /* translators: %s: number of entries */
10288 'countRepair' => __('%s to rewrite', 'mxchat'),
10289 /* translators: %s: number of entries */
10290 'countCorrect' => __('%s already correct', 'mxchat'),
10291 /* translators: %s: number of entries */
10292 'countChanged' => __('%s left alone', 'mxchat'),
10293 /* translators: %s: number of entries whose page was edited after they were imported */
10294 'countEdited' => __('%s edited since import', 'mxchat'),
10295 'editedNotRewritten' => __('Edited since import', 'mxchat'),
10296 /* translators: %s: number of entries */
10297 'countSkipped' => __('%s skipped', 'mxchat'),
10298 /* translators: %s: number of entries */
10299 'countRewritten' => __('%s rewritten', 'mxchat'),
10300 /* translators: %s: number of entries */
10301 'countFailed' => __('%s not rewritten', 'mxchat'),
10302 /* translators: %s: number of further entries not shown in the list */
10303 'andMore' => __('and %s more', 'mxchat'),
10304 /* translators: %s: number of entries */
10305 'othersTitle' => __('Left alone or skipped (%s)', 'mxchat'),
10306 'leftAlone' => __('Left alone', 'mxchat'),
10307 'skipped' => __('Skipped', 'mxchat'),
10308 'rewritten' => __('Rewritten', 'mxchat'),
10309 'notRewritten' => __('Not rewritten', 'mxchat'),
10310 'noLongerNeeded' => __('No longer needed', 'mxchat'),
10311 /* translators: %s: number of entries */
10312 'apply' => __('Rewrite %s entries', 'mxchat'),
10313 /* translators: %s: number of entries still to rewrite */
10314 'resume' => __('Continue, %s left', 'mxchat'),
10315 /* translators: %s: number of entries */
10316 'done' => __('%s entries rewritten and embedded again.', 'mxchat'),
10317 /* translators: 1: entries rewritten, 2: entries that could not be rewritten */
10318 'doneWithFailures' => __('%1$s entries rewritten. %2$s could not be rewritten and were left as they were.', 'mxchat'),
10319 'nothingToDo' => __('Every entry checked is already correct. Nothing to rewrite.', 'mxchat'),
10320 /* translators: %s: the reason the check stopped */
10321 'scanStopped' => __('The check stopped early: %s', 'mxchat'),
10322 'reasons' => array(
10323 'entities' => __('HTML character codes', 'mxchat'),
10324 'currency' => __('Currency signs', 'mxchat'),
10325 'combining marks' => __('Accents and marks', 'mxchat'),
10326 'symbols' => __('Symbols', 'mxchat'),
10327 'joiners' => __('Joined letters', 'mxchat'),
10328 'RTL' => __('Right-to-left order', 'mxchat'),
10329 'edited' => __('Edited since import', 'mxchat'),
10330 ),
10331 ));
10332 // Per-entry "View indexed content" inspector (plan-d8cb4b) reuses the
10333 // Testing tab's match-card / chunk-detail components, which are scoped
10334 // under .mxch-testing-results. Load that stylesheet here so the inspector
10335 // modal renders identically to the Testing tab.
10336 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css'), $version);
10337 break;
10338
10339 case 'mxchat-transcripts':
10340 // Load admin sidebar CSS first (shared styles for sidebar navigation)
10341 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10342 // Load transcripts-specific styles
10343 wp_enqueue_style('mxchat-chat-transcripts-css', $plugin_url . 'css/chat-transcripts.css', array('mxchat-admin-sidebar-css'), $version);
10344 wp_enqueue_script('mxchat-transcripts-js', $plugin_url . 'js/mxchat_transcripts.js', array('jquery'), $version, true);
10345 break;
10346
10347 case 'mxchat-actions':
10348 // Load admin sidebar CSS first (shared styles for sidebar navigation)
10349 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10350 // Load actions-specific styles
10351 wp_enqueue_style('mxchat-actions-css', $plugin_url . 'css/actions.css', array('mxchat-admin-sidebar-css'), $version);
10352 wp_enqueue_script('mxchat-actions-js', $plugin_url . 'js/mxchat_actions.js', array('jquery'), $version, true);
10353
10354 // Localize script data for actions page
10355 $is_activated = get_option('mxchat_pro_license_status') === 'active';
10356 wp_localize_script('mxchat-actions-js', 'mxchActionsData', array(
10357 'ajaxUrl' => admin_url('admin-ajax.php'),
10358 'nonce' => wp_create_nonce('mxchat_actions_nonce'),
10359 'addNonce' => wp_create_nonce('mxchat_add_intent_nonce'),
10360 'editNonce' => wp_create_nonce('mxchat_edit_intent'),
10361 'deleteNonce' => wp_create_nonce('mxchat_delete_intent_nonce'),
10362 'toggleNonce' => wp_create_nonce('mxchat_actions_nonce'),
10363 'addPhraseNonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
10364 'deletePhraseNonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
10365 'getPhrasesNonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
10366 'deleteLegacyNonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
10367 'isActivated' => $is_activated,
10368 'i18n' => array(
10369 'confirmDelete' => __('Are you sure you want to delete this trigger phrase?', 'mxchat'),
10370 'confirmBulkDelete' => __('Are you sure you want to delete the selected trigger phrases?', 'mxchat'),
10371 'saving' => __('Saving...', 'mxchat'),
10372 'saved' => __('Saved successfully!', 'mxchat'),
10373 'error' => __('An error occurred. Please try again.', 'mxchat'),
10374 'proRequired' => __('This feature requires MxChat Pro.', 'mxchat'),
10375 'addonRequired' => __('This feature requires an add-on.', 'mxchat')
10376 )
10377 ));
10378 break;
10379
10380 case 'mxchat-activation':
10381 // Load admin sidebar CSS first (shared styles for sidebar navigation)
10382 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10383 // Load pro page-specific styles
10384 wp_enqueue_style('mxchat-pro-css', $plugin_url . 'css/admin-pro.css', array('mxchat-admin-sidebar-css'), $version);
10385 // Load pro page JavaScript
10386 wp_enqueue_script('mxchat-pro-js', $plugin_url . 'js/mxchat_pro.js', array('jquery'), $version, true);
10387 // Load activation script for license activation/deactivation
10388 wp_enqueue_script('mxchat-activation-js', $plugin_url . 'js/activation-script.js', array('jquery'), $version, true);
10389 break;
10390
10391 case 'mxchat-content':
10392 // Load admin sidebar CSS (shared styles for sidebar navigation)
10393 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10394 // Load content page-specific styles
10395 wp_enqueue_style('mxchat-content-css', $plugin_url . 'css/admin-content.css', array('mxchat-admin-sidebar-css'), $version);
10396 // Load content page JavaScript
10397 wp_enqueue_script('mxchat-content-js', $plugin_url . 'js/mxchat-content.js', array('jquery'), $version, true);
10398 break;
10399
10400 case 'mxchat-api-access':
10401 // Shared sidebar shell (CSS + JS for tab switching / mobile menu / copy buttons).
10402 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10403 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10404 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10405 'copied' => __('Copied', 'mxchat'),
10406 ));
10407 break;
10408
10409 case 'mxchat-max':
10410 case 'mxchat-onboarding':
10411 // Onboarding page — uses the shared admin shell PLUS the wizard
10412 // overlay (plan-905439). admin-onboarding-wizard.css scopes the
10413 // WP-chrome surgery to body.mxchat-onboarding-focused so it only
10414 // applies on THIS page. The body class is added below via the
10415 // admin_body_class filter.
10416 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10417 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10418 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10419 'copied' => __('Copied', 'mxchat'),
10420 ));
10421 // admin-style.css provides the .mxchat-instructions-modal-* classes
10422 // the new Behavior step's "View Sample Instructions" modal needs.
10423 // Enqueued AFTER admin-sidebar.css but BEFORE the wizard overlay
10424 // so the wizard's own rules win where they collide. plan-a2e4d6.
10425 wp_enqueue_style('mxchat-admin-style-css', $plugin_url . 'css/admin-style.css', array('mxchat-admin-sidebar-css'), $version);
10426 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);
10427 wp_enqueue_script('mxchat-admin-onboarding-wizard-js', $plugin_url . 'js/admin-onboarding-wizard.js', array(), $version, true);
10428 break;
10429 default:
10430 // Settings page: load the shared shell JS for #hash deep-linking
10431 // to tabs (plan 4ede16). The page's own inline nav script owns
10432 // clicks/accordion behaviour; the shell script adds load +
10433 // hashchange activation and replaceState so plain href="#tab"
10434 // anchors and shared URLs land on the right tab. Kept inside the
10435 // default case because the settings page also needs everything
10436 // below (Testing tab chatbot + streaming assets).
10437 if ($current_page === 'mxchat-settings') {
10438 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10439 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10440 'copied' => __('Copied', 'mxchat'),
10441 ));
10442 }
10443
10444 wp_enqueue_script(
10445 'mxchat-test-streaming-js',
10446 $plugin_url . 'js/mxchat-test-streaming.js',
10447 ['jquery'],
10448 $version,
10449 true
10450 );
10451
10452 wp_localize_script('mxchat-test-streaming-js', 'mxchatTestStreamingAjax', [
10453 'ajax_url' => admin_url('admin-ajax.php'),
10454 'nonce' => wp_create_nonce('mxchat_test_streaming_nonce'),
10455 'settings_nonce' => wp_create_nonce('mxchat_save_setting_nonce')
10456 ]);
10457
10458 // Testing Tab: Load chatbot assets for the embedded testing chatbot
10459 wp_enqueue_style('mxchat-chat-css', $plugin_url . 'css/chat-style.css', array(), $version);
10460 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css', 'mxchat-chat-css'), $version);
10461
10462 wp_enqueue_script('mxchat-chat-js', $plugin_url . 'js/chat-script.js', array('jquery'), $version, true);
10463 wp_enqueue_script('mxchat-admin-testing-js', $plugin_url . 'js/admin-testing-tab.js', array('jquery', 'mxchat-chat-js'), $version, true);
10464
10465 // Allow add-ons to enqueue their public CSS/JS for the testing chatbot
10466 do_action('mxchat_enqueue_testing_tab_assets');
10467
10468 // Localize mxchatChat for the chatbot JS (same settings as frontend)
10469 $options = get_option('mxchat_options', array());
10470 $prompts_options = get_option('mxchat_prompts_options', array());
10471 $theme_options = get_option('mxchat_theme_options', array());
10472 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
10473 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
10474 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
10475
10476 wp_localize_script('mxchat-chat-js', 'mxchatChat', array(
10477 'ajax_url' => admin_url('admin-ajax.php'),
10478 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
10479 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
10480 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
10481 'contextual_awareness_toggle' => isset($options['contextual_awareness_toggle']) ? $options['contextual_awareness_toggle'] : 'off',
10482 'link_target_toggle' => $options['link_target_toggle'] ?? 'off',
10483 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
10484 'complianz_toggle' => isset($options['complianz_toggle']) && $options['complianz_toggle'] === 'on',
10485 'user_message_bg_color' => $options['user_message_bg_color'] ?? '#fff',
10486 'user_message_font_color' => $options['user_message_font_color'] ?? '#212121',
10487 'bot_message_bg_color' => $options['bot_message_bg_color'] ?? '#212121',
10488 'bot_message_font_color' => $options['bot_message_font_color'] ?? '#fff',
10489 'top_bar_bg_color' => $options['top_bar_bg_color'] ?? '#212121',
10490 'send_button_font_color' => $options['send_button_font_color'] ?? '#212121',
10491 'close_button_color' => $options['close_button_color'] ?? '#fff',
10492 'chatbot_background_color' => $options['chatbot_background_color'] ?? '#212121',
10493 'chatbot_bg_color' => $options['chatbot_bg_color'] ?? '#fff',
10494 'icon_color' => $options['icon_color'] ?? '#fff',
10495 'chat_input_font_color' => $options['chat_input_font_color'] ?? '#212121',
10496 'chat_persistence_toggle' => 'off', // Always off for testing chatbot
10497 'appendWidgetToBody' => 'off',
10498 'live_agent_message_bg_color' => $options['live_agent_message_bg_color'] ?? '#ffffff',
10499 'live_agent_message_font_color' => $options['live_agent_message_font_color'] ?? '#333333',
10500 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
10501 'mode_indicator_bg_color' => $options['mode_indicator_bg_color'] ?? '#767676',
10502 'mode_indicator_font_color' => $options['mode_indicator_font_color'] ?? '#ffffff',
10503 'toolbar_icon_color' => $options['toolbar_icon_color'] ?? '#212121',
10504 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
10505 'email_collection_enabled' => 'off', // No email collection in testing
10506 'initial_email_state' => null,
10507 'skip_email_check' => true,
10508 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
10509 'skip_inline_colors' => $skip_inline_colors,
10510 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
10511 ));
10512
10513 // Localize testing tab script data
10514 wp_localize_script('mxchat-admin-testing-js', 'mxchatAdminTestData', array(
10515 'ajaxUrl' => admin_url('admin-ajax.php'),
10516 'nonce' => wp_create_nonce('mxchat_test_nonce'),
10517 'isAdmin' => true,
10518 'testingEnabled' => true
10519 ));
10520
10521 // Add testing enabled flag for the chatbot to return debug data
10522 add_action('admin_footer', function() {
10523 echo '<script>window.mxchatTestingEnabled = true;</script>';
10524 });
10525
10526 break;
10527 }
10528 }
10529 /**
10530 * The base mxchatAdmin payload shared by every MxChat admin page.
10531 *
10532 * Extracted (plan c192a4) so page-specific re-localizations of the SAME
10533 * global — the knowledge page prints a second `var mxchatAdmin` via the
10534 * mxchat-knowledge-processing handle, and last-printed wins — can merge
10535 * their extra keys on top of this instead of silently narrowing the object
10536 * that mxchat-admin.js also reads on that page.
10537 */
10538 private function mxchat_get_admin_base_localization() {
10539 return array(
10540 'ajax_url' => admin_url('admin-ajax.php'),
10541 'nonce' => wp_create_nonce('mxchat_admin_nonce'),
10542 'admin_url' => admin_url(),
10543 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce'),
10544 'inline_edit_nonce' => wp_create_nonce('mxchat_save_inline_nonce'),
10545 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10546 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
10547 'actions_nonce' => wp_create_nonce('mxchat_actions_nonce'),
10548 'add_intent_nonce' => wp_create_nonce('mxchat_add_intent_nonce'),
10549 'edit_intent_nonce' => wp_create_nonce('mxchat_edit_intent'),
10550 'add_phrase_nonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
10551 'delete_phrase_nonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
10552 'get_phrases_nonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
10553 'delete_legacy_nonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
10554 'toggle_action_nonce' => wp_create_nonce('mxchat_actions_nonce'),
10555 'fetch_openrouter_models_nonce' => wp_create_nonce('mxchat_fetch_openrouter_models'),
10556 'is_activated' => $this->is_activated ? '1' : '0',
10557 'status_refresh_interval' => 5000,
10558 'discard_changes_confirm' => __('Discard your unsaved changes?', 'mxchat'),
10559 'unsaved_label' => __('Unsaved', 'mxchat'),
10560 // Live-agent schedule status text (plan 8ccaa2).
10561 'i18n_scheduled_hours' => __('Scheduled hours', 'mxchat'),
10562 'i18n_always_available' => __('Always available', 'mxchat'),
10563 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10564 'ajaxurl' => admin_url('admin-ajax.php')
10565 );
10566 }
10567 private function localize_admin_scripts($current_page) {
10568 // Base localization data for main admin script
10569 $base_data = $this->mxchat_get_admin_base_localization();
10570
10571 // Localize main admin script with base data
10572 wp_localize_script('mxchat-admin-js', 'mxchatAdmin', $base_data);
10573
10574 // Canonical chat-model catalog for the modal picker grid
10575 // (plan-d14e89). Adding a model in class-mxchat-model-catalog.php
10576 // automatically appears here.
10577 if (!class_exists('MxChat_Model_Catalog')) {
10578 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10579 }
10580 wp_localize_script('mxchat-admin-js', 'mxchatChatModelCatalog', MxChat_Model_Catalog::js_picker_shape());
10581
10582 // Page-specific localizations
10583 $this->localize_page_specific_scripts($current_page);
10584 }
10585 private function localize_page_specific_scripts($current_page) {
10586 switch ($current_page) {
10587 case 'mxchat-prompts':
10588 // Status updater localization
10589 wp_localize_script('mxchat-status-updater', 'mxchat_status_data', array(
10590 'ajax_url' => admin_url('admin-ajax.php'),
10591 'nonce' => wp_create_nonce('mxchat_status_nonce')
10592 ));
10593
10594 // Content selector localization
10595 wp_localize_script('mxchat-content-selector-js', 'mxchatSelector', array(
10596 'ajaxurl' => admin_url('admin-ajax.php'),
10597 'nonce' => wp_create_nonce('mxchat_content_selector_nonce'),
10598 'i18n' => array(
10599 'searchPlaceholder' => __('Search posts and pages...', 'mxchat'),
10600 'selectAll' => __('Select All', 'mxchat'),
10601 'process' => __('Process Selected', 'mxchat'),
10602 'cancel' => __('Cancel', 'mxchat'),
10603 'noResults' => __('No content found.', 'mxchat'),
10604 'extractingPdfs' => __('extracting %d PDF(s)...', 'mxchat'),
10605 'pdfExtractedSuffix' => __(' (%d PDF(s) extracted)', 'mxchat')
10606 )
10607 ));
10608
10609 // This prints a SECOND `var mxchatAdmin` (after mxchat-admin-js's
10610 // base one) and last-printed wins — so it must carry the FULL base
10611 // payload plus the knowledge-page extras, or every mxchatAdmin.*
10612 // key mxchat-admin.js reads on this page comes up undefined
10613 // (plan c192a4). Extras merge last: status_refresh_interval stays
10614 // at this page's faster 2000ms cadence.
10615 wp_localize_script('mxchat-knowledge-processing', 'mxchatAdmin', array_merge(
10616 $this->mxchat_get_admin_base_localization(),
10617 array(
10618 'status_nonce' => wp_create_nonce('mxchat_status_nonce'),
10619 'queue_nonce' => wp_create_nonce('mxchat_queue_nonce'),
10620 'stop_nonce' => wp_create_nonce('mxchat_stop_processing_action'),
10621 'settings_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10622 'entries_nonce' => wp_create_nonce('mxchat_entries_nonce'),
10623 'status_refresh_interval' => 2000,
10624 'bot_id' => isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default'
10625 )
10626 ));
10627 break;
10628
10629 case 'mxchat-activation':
10630 // Same last-printed-wins clobber as the knowledge page (plan c192a4,
10631 // fixed here by a5b297): this second `var mxchatAdmin` must carry
10632 // the full base payload plus the page extras.
10633 wp_localize_script('mxchat-activation-js', 'mxchatAdmin', array_merge(
10634 $this->mxchat_get_admin_base_localization(),
10635 array(
10636 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce')
10637 )
10638 ));
10639 break;
10640
10641 case 'mxchat-content':
10642 wp_localize_script('mxchat-content-js', 'mxchatContent', array(
10643 'ajaxUrl' => admin_url('admin-ajax.php'),
10644 'nonce' => wp_create_nonce('mxchat_content_nonce'),
10645 'settingNonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10646 'previewUrl' => home_url('/?p='),
10647 'isActivated' => $this->is_activated(),
10648 'hasAdvancedContent' => apply_filters('mxchat_content_pro_feature', false, 'seo_readability'),
10649 'hasGSC' => apply_filters('mxchat_content_pro_feature', false, 'gsc_integration'),
10650 // Whether Search Console is actually LINKED — distinct from hasGSC
10651 // (add-on active). Reads the same option the add-on's settings
10652 // card keys off; harmless false when the add-on is absent.
10653 'gscLinked' => (bool) get_option('mxchat_gsc_connected', false),
10654 'seoOptimize' => array(
10655 'meta_description' => ($options['seo_optimize_meta_desc'] ?? 'on') === 'on',
10656 'seo_title' => ($options['seo_optimize_seo_title'] ?? 'on') === 'on',
10657 'slug' => ($options['seo_optimize_slug'] ?? 'on') === 'on',
10658 'readability' => ($options['seo_optimize_readability'] ?? 'on') === 'on',
10659 'internal_links' => ($options['seo_optimize_internal_links'] ?? 'on') === 'on',
10660 'img_alt' => ($options['seo_optimize_img_alt'] ?? 'on') === 'on',
10661 'featured_img' => ($options['seo_optimize_featured_img'] ?? 'on') === 'on',
10662 ),
10663 'i18n' => array(
10664 'generating' => __('Generating...', 'mxchat'),
10665 'planning' => __('Planning content structure...', 'mxchat'),
10666 'images' => __('Generating images...', 'mxchat'),
10667 'writing' => __('Writing full content...', 'mxchat'),
10668 'creating' => __('Creating WordPress post...', 'mxchat'),
10669 'done' => __('Content generated successfully!', 'mxchat'),
10670 'error' => __('An error occurred. Please try again.', 'mxchat'),
10671 'editSuccess' => __('Content updated successfully.', 'mxchat'),
10672 'promptEmpty' => __('Please enter a prompt.', 'mxchat'),
10673 )
10674 ));
10675 break;
10676
10677 case 'mxchat-transcripts':
10678 // Get chart data for localization
10679 $chart_data = $this->get_transcripts_chart_data();
10680
10681 // Same last-printed-wins clobber as the knowledge page (plan c192a4,
10682 // fixed here by a5b297): merge the base payload under the page keys.
10683 // export_nonce/setting_nonce/ajax_url duplicate base values identically;
10684 // delete_nonce and translate_nonce are the page-specific adds.
10685 wp_localize_script('mxchat-transcripts-js', 'mxchatAdmin', array_merge(
10686 $this->mxchat_get_admin_base_localization(),
10687 array(
10688 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
10689 'delete_nonce' => wp_create_nonce('mxchat_delete_chat_history'),
10690 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10691 'translate_nonce' => wp_create_nonce('mxchat_translate_messages')
10692 )
10693 ));
10694
10695 // Localize chart data separately - use array_values to ensure proper JSON array encoding
10696 wp_localize_script('mxchat-transcripts-js', 'mxchatChartData', array(
10697 'labels' => array_values($chart_data['labels']),
10698 'chats' => array_values($chart_data['chats']),
10699 'messages' => array_values($chart_data['messages'])
10700 ));
10701 break;
10702
10703 case 'mxchat-settings':
10704 default:
10705 // The 'mxchatStyleSettings' localize that used to live here was
10706 // REMOVED by plan-mxchat-20260731-c63fb6.
10707 //
10708 // It targeted the script handle 'mxchat-color-picker', which is
10709 // registered/enqueued NOWHERE in the plugin — so wp_localize_script()
10710 // returned false and printed nothing. That accident was the only
10711 // thing keeping it from being a live secret disclosure: the payload
10712 // carried loops_api_key, live_agent_secret_key and
10713 // live_agent_bot_token in full, and this whole method ran for any
10714 // logged-in user (see the capability gate added to
10715 // mxchat_enqueue_admin_assets). One future
10716 // wp_enqueue_script('mxchat-color-picker', ...) would have printed
10717 // three secrets into page HTML for Subscribers.
10718 //
10719 // Nothing consumed the object: a tree-wide grep for
10720 // 'mxchatStyleSettings' returns only this call site, and js/ names
10721 // loops_api_key only at mxchat-admin.js:572, as a field-NAME string
10722 // in an autosave allowlist — not a value read. Removed entirely
10723 // rather than deleting just the three secret keys, so there is no
10724 // dead payload left for someone to re-arm.
10725 break;
10726 }
10727
10728 // Additional localization that was in the original code
10729 wp_localize_script('mxchat-admin-js', 'mxchatPromptsAdmin', array(
10730 'ajax_url' => admin_url('admin-ajax.php'),
10731 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10732 ));
10733 }
10734
10735 public function mxchat_sanitize($input) {
10736 $new_input = array();
10737
10738 if (isset($input['api_key'])) {
10739 $new_input['api_key'] = sanitize_text_field($input['api_key']);
10740 }
10741
10742 if (isset($input['similarity_threshold'])) {
10743 $new_input['similarity_threshold'] = absint($input['similarity_threshold']); // Ensure it's an integer
10744 $new_input['similarity_threshold'] = min(max($new_input['similarity_threshold'], 20), 85); // Enforce range
10745 }
10746
10747 if (isset($input['rag_sources_limit'])) {
10748 $new_input['rag_sources_limit'] = absint($input['rag_sources_limit']); // Ensure it's an integer
10749 $new_input['rag_sources_limit'] = min(max($new_input['rag_sources_limit'], 3), 10); // Enforce range 3-10
10750 }
10751
10752 if (isset($input['rag_chunks_limit'])) {
10753 $new_input['rag_chunks_limit'] = absint($input['rag_chunks_limit']); // Ensure it's an integer
10754 $new_input['rag_chunks_limit'] = min(max($new_input['rag_chunks_limit'], 8), 20); // Enforce range 8-20
10755 }
10756
10757 if (isset($input['xai_api_key'])) {
10758 $new_input['xai_api_key'] = sanitize_text_field($input['xai_api_key']);
10759 }
10760
10761 if (isset($input['claude_api_key'])) {
10762 $new_input['claude_api_key'] = sanitize_text_field($input['claude_api_key']);
10763 }
10764
10765 if (isset($input['enable_streaming_toggle'])) {
10766 $new_input['enable_streaming_toggle'] = ($input['enable_streaming_toggle'] === 'on') ? 'on' : 'off';
10767 } else {
10768 // If checkbox not checked, it won't be in $input, so set to 'off'
10769 $new_input['enable_streaming_toggle'] = 'off';
10770 }
10771
10772 if (isset($input['enable_web_search'])) {
10773 $new_input['enable_web_search'] = ($input['enable_web_search'] === 'on') ? 'on' : 'off';
10774 } else {
10775 // If checkbox not checked, it won't be in $input, so set to 'off'
10776 $new_input['enable_web_search'] = 'off';
10777 }
10778
10779 if (isset($input['deepseek_api_key'])) {
10780 $new_input['deepseek_api_key'] = sanitize_text_field($input['deepseek_api_key']);
10781 }
10782
10783 if (isset($input['gemini_api_key'])) {
10784 $new_input['gemini_api_key'] = sanitize_text_field($input['gemini_api_key']);
10785 }
10786
10787 if (isset($input['enable_woocommerce_integration'])) {
10788 $new_input['enable_woocommerce_integration'] = $input['enable_woocommerce_integration'] === 'on' ? 'on' : 'off';
10789 }
10790
10791 if (isset($input['privacy_toggle'])) {
10792 $new_input['privacy_toggle'] = $input['privacy_toggle'];
10793 }
10794
10795 if (isset($input['complianz_toggle'])) {
10796 $new_input['complianz_toggle'] = $input['complianz_toggle'];
10797 }
10798
10799 // Handle custom privacy text input
10800 if (isset($input['privacy_text'])) {
10801 // Allow basic HTML for links
10802 $new_input['privacy_text'] = wp_kses_post($input['privacy_text']);
10803 }
10804
10805 if (isset($input['system_prompt_instructions'])) {
10806 $new_input['system_prompt_instructions'] = sanitize_textarea_field($input['system_prompt_instructions']);
10807 }
10808
10809 if (isset($input['mxchat_pro_email'])) {
10810 $new_input['mxchat_pro_email'] = sanitize_email($input['mxchat_pro_email']);
10811 }
10812
10813 if (isset($input['mxchat_activation_key'])) {
10814 $new_input['mxchat_activation_key'] = sanitize_text_field($input['mxchat_activation_key']);
10815 }
10816
10817 if (isset($input['append_to_body'])) {
10818 $new_input['append_to_body'] = $input['append_to_body'] === 'on' ? 'on' : 'off';
10819 }
10820
10821 // Post type visibility settings
10822 if (isset($input['post_type_visibility_mode'])) {
10823 $allowed_modes = array('all', 'include', 'exclude');
10824 $new_input['post_type_visibility_mode'] = in_array($input['post_type_visibility_mode'], $allowed_modes)
10825 ? $input['post_type_visibility_mode']
10826 : 'all';
10827 }
10828
10829 if (isset($input['post_type_visibility_list'])) {
10830 if (is_array($input['post_type_visibility_list'])) {
10831 $new_input['post_type_visibility_list'] = array_map('sanitize_key', $input['post_type_visibility_list']);
10832 } else {
10833 $new_input['post_type_visibility_list'] = array();
10834 }
10835 }
10836
10837 if (isset($input['contextual_awareness_toggle'])) {
10838 $new_input['contextual_awareness_toggle'] = $input['contextual_awareness_toggle'] === 'on' ? 'on' : 'off';
10839 }
10840
10841 if (isset($input['citation_links_toggle'])) {
10842 $new_input['citation_links_toggle'] = $input['citation_links_toggle'] === 'on' ? 'on' : 'off';
10843 }
10844
10845 if (isset($input['strip_unapproved_links_toggle'])) {
10846 $new_input['strip_unapproved_links_toggle'] = $input['strip_unapproved_links_toggle'] === 'on' ? 'on' : 'off';
10847 }
10848
10849 // Satisfaction rating prompt — defaults ON when unchecked (so first save
10850 // doesn't accidentally disable it). The form posts 'on' when checked;
10851 // unchecked checkboxes don't post a value at all, so we infer 'off' only
10852 // when the autosave/PHP request explicitly clears it via empty string.
10853 if (array_key_exists('satisfaction_rating_enabled', $input)) {
10854 $new_input['satisfaction_rating_enabled'] = $input['satisfaction_rating_enabled'] === 'on' ? 'on' : 'off';
10855 }
10856
10857 // Satisfaction rating customization (plan-141a12). Idle is clamped 5-600;
10858 // the 4 text strings are sanitized + capped server-side. Blank values are
10859 // preserved so the integrator falls back to translated defaults.
10860 if (array_key_exists('satisfaction_rating_idle_seconds', $input)) {
10861 $new_input['satisfaction_rating_idle_seconds'] = max(5, min(600, intval($input['satisfaction_rating_idle_seconds'])));
10862 }
10863
10864 // Max input length in characters (plan a3fae2 part C). 0 = unlimited (default,
10865 // preserves current behavior). Clamp to a sane ceiling so a typo can't lock out
10866 // all input. REQUIRED here — mxchat_sanitize() rebuilds the array from a whitelist,
10867 // so without this isset-handler the key is stripped on the next save of ANY field
10868 // (the 2c02ea global-rate-limit footgun).
10869 if (array_key_exists('max_input_length', $input)) {
10870 $new_input['max_input_length'] = max(0, min(100000, intval($input['max_input_length'])));
10871 }
10872 if (array_key_exists('satisfaction_rating_question', $input)) {
10873 $new_input['satisfaction_rating_question'] = mb_substr(sanitize_text_field($input['satisfaction_rating_question']), 0, 200);
10874 }
10875 if (array_key_exists('satisfaction_rating_thanks', $input)) {
10876 $new_input['satisfaction_rating_thanks'] = mb_substr(sanitize_text_field($input['satisfaction_rating_thanks']), 0, 300);
10877 }
10878 if (array_key_exists('satisfaction_rating_placeholder', $input)) {
10879 $new_input['satisfaction_rating_placeholder'] = mb_substr(sanitize_text_field($input['satisfaction_rating_placeholder']), 0, 200);
10880 }
10881 if (array_key_exists('satisfaction_rating_saved', $input)) {
10882 $new_input['satisfaction_rating_saved'] = mb_substr(sanitize_text_field($input['satisfaction_rating_saved']), 0, 200);
10883 }
10884
10885 if (isset($input['top_bar_title'])) {
10886 $new_input['top_bar_title'] = sanitize_text_field($input['top_bar_title']);
10887 }
10888
10889 if (isset($input['ai_agent_text'])) {
10890 $new_input['ai_agent_text'] = sanitize_text_field($input['ai_agent_text']);
10891 }
10892
10893 if (isset($input['enable_email_block'])) {
10894 $new_input['enable_email_block'] = sanitize_text_field($input['enable_email_block']);
10895 }
10896
10897 if (isset($input['email_blocker_header_content'])) {
10898 // wp_kses_post() allows standard HTML tags permitted by WordPress
10899 $new_input['email_blocker_header_content'] = wp_kses_post($input['email_blocker_header_content']);
10900 }
10901 if (isset($input['email_blocker_button_text'])) {
10902 $new_input['email_blocker_button_text'] = sanitize_text_field($input['email_blocker_button_text']);
10903 }
10904 // Sanitize name field toggle
10905 if (isset($input['enable_name_field'])) {
10906 $new_input['enable_name_field'] = ($input['enable_name_field'] === 'on') ? 'on' : 'off';
10907 } else {
10908 $new_input['enable_name_field'] = 'off';
10909 }
10910 // Sanitize name field placeholder
10911 if (isset($input['name_field_placeholder'])) {
10912 $new_input['name_field_placeholder'] = sanitize_text_field($input['name_field_placeholder']);
10913 }
10914 // Consent checkbox (b062c4). Both toggles default off — an unchecked box
10915 // posts nothing, so the else-branch is what makes unticking stick.
10916 if (isset($input['enable_consent_checkbox'])) {
10917 $new_input['enable_consent_checkbox'] = ($input['enable_consent_checkbox'] === 'on') ? 'on' : 'off';
10918 } else {
10919 $new_input['enable_consent_checkbox'] = 'off';
10920 }
10921 if (isset($input['consent_checkbox_label'])) {
10922 // Shared allowlist with the render path — see MxChat_Utils::sanitize_consent_label().
10923 $new_input['consent_checkbox_label'] = MxChat_Utils::sanitize_consent_label($input['consent_checkbox_label']);
10924 }
10925 if (isset($input['consent_checkbox_required'])) {
10926 $new_input['consent_checkbox_required'] = ($input['consent_checkbox_required'] === 'on') ? 'on' : 'off';
10927 } else {
10928 $new_input['consent_checkbox_required'] = 'off';
10929 }
10930 // Lead capture options (c0cfaf). Default OFF gets the else-branch so
10931 // unticking sticks; default ON deliberately has NO else — an absent key
10932 // must stay absent so the reader's ?? 'on' default applies, otherwise
10933 // every autosave of an unrelated field would silently flip the setting
10934 // off on installs that never touched it (the 2c02ea bug class).
10935 if (isset($input['lead_capture_logged_in_toggle'])) {
10936 $new_input['lead_capture_logged_in_toggle'] = ($input['lead_capture_logged_in_toggle'] === 'on') ? 'on' : 'off';
10937 } else {
10938 $new_input['lead_capture_logged_in_toggle'] = 'off';
10939 }
10940 if (isset($input['lead_capture_require_email_toggle'])) {
10941 $new_input['lead_capture_require_email_toggle'] = ($input['lead_capture_require_email_toggle'] === 'on') ? 'on' : 'off';
10942 }
10943 if (isset($input['intro_message'])) {
10944 $new_input['intro_message'] = wp_kses_post($input['intro_message']); // Use wp_kses_post instead
10945 }
10946
10947 if (isset($input['input_copy'])) {
10948 $new_input['input_copy'] = sanitize_text_field($input['input_copy']);
10949 }
10950
10951 if (isset($input['rate_limit_message'])) {
10952 $new_input['rate_limit_message'] = sanitize_text_field($input['rate_limit_message']);
10953 }
10954
10955 // Handle the new rate limits format
10956 if (isset($input['rate_limits']) && is_array($input['rate_limits'])) {
10957 $new_input['rate_limits'] = array();
10958 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
10959 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
10960
10961 foreach ($input['rate_limits'] as $role_id => $settings) {
10962 $new_input['rate_limits'][$role_id] = array();
10963
10964 // Sanitize limit
10965 if (isset($settings['limit'])) {
10966 $limit = sanitize_text_field($settings['limit']);
10967 // Accept presets, 'unlimited', the '__custom__' sentinel, OR any positive
10968 // integer (custom value) — mirrors the global branch (plan-2c02ea). Without
10969 // the custom path the per-role custom input was dropped and reset to the role
10970 // default on every save (plan-7e23e7).
10971 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
10972 $new_input['rate_limits'][$role_id]['limit'] = $limit;
10973 } else {
10974 $new_input['rate_limits'][$role_id]['limit'] = ($role_id === 'logged_out') ? '10' : '100'; // Default
10975 }
10976 }
10977
10978 // Preserve the per-role custom value (mirrors the global branch's limit_custom).
10979 if (isset($settings['limit_custom'])) {
10980 $new_input['rate_limits'][$role_id]['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $settings['limit_custom']);
10981 }
10982
10983 // Sanitize timeframe
10984 if (isset($settings['timeframe'])) {
10985 $timeframe = sanitize_text_field($settings['timeframe']);
10986 if (in_array($timeframe, $allowed_timeframes, true)) {
10987 $new_input['rate_limits'][$role_id]['timeframe'] = $timeframe;
10988 } else {
10989 $new_input['rate_limits'][$role_id]['timeframe'] = 'daily'; // Default
10990 }
10991 }
10992
10993 // Sanitize message
10994 if (isset($settings['message'])) {
10995 $new_input['rate_limits'][$role_id]['message'] = sanitize_textarea_field($settings['message']);
10996 }
10997 }
10998 }
10999
11000 // Handle the whole-chatbot global rate limit (plan-mxchat-20260603-2c02ea).
11001 // Mirrors the per-role block above, adapted to the single global shape. Without
11002 // this branch the whitelist-rebuild dropped rate_limits_global entirely on every
11003 // save, so the global cap silently fell back to its 'unlimited' default.
11004 // MUST accept arbitrary positive integers so the custom-value path (d55f65) is not regressed.
11005 if (isset($input['rate_limits_global']) && is_array($input['rate_limits_global'])) {
11006 $g = $input['rate_limits_global'];
11007 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
11008 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
11009 $global_out = array();
11010
11011 if (isset($g['limit'])) {
11012 $limit = sanitize_text_field($g['limit']);
11013 // Accept presets, 'unlimited', the '__custom__' sentinel (resolved by the
11014 // autosave handler / renderer), OR any positive integer (custom value).
11015 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
11016 $global_out['limit'] = $limit;
11017 } else {
11018 $global_out['limit'] = 'unlimited';
11019 }
11020 }
11021
11022 if (isset($g['limit_custom'])) {
11023 $global_out['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $g['limit_custom']);
11024 }
11025
11026 if (isset($g['timeframe'])) {
11027 $timeframe = sanitize_text_field($g['timeframe']);
11028 $global_out['timeframe'] = in_array($timeframe, $allowed_timeframes, true) ? $timeframe : 'daily';
11029 }
11030
11031 if (isset($g['message'])) {
11032 $global_out['message'] = sanitize_textarea_field($g['message']);
11033 }
11034
11035 $new_input['rate_limits_global'] = $global_out;
11036 }
11037
11038 if (isset($input['pre_chat_message'])) {
11039 $new_input['pre_chat_message'] = sanitize_textarea_field($input['pre_chat_message']);
11040 }
11041
11042 if (isset($input['voyage_api_key'])) {
11043 $new_input['voyage_api_key'] = sanitize_text_field($input['voyage_api_key']);
11044 }
11045
11046 // Add to your sanitize function
11047 if (isset($input['embedding_model'])) {
11048 // Catalog refactor (plan-d14e89): allowlist derived from the canonical
11049 // catalog in includes/class-mxchat-model-catalog.php.
11050 if (!class_exists('MxChat_Model_Catalog')) {
11051 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
11052 }
11053 $allowed_models = MxChat_Model_Catalog::embedding_model_ids();
11054 if (in_array($input['embedding_model'], $allowed_models)) {
11055 $new_input['embedding_model'] = sanitize_text_field($input['embedding_model']);
11056 }
11057 }
11058
11059 if (isset($input['model'])) {
11060 if ($input['model'] === 'openrouter') {
11061 $new_input['model'] = 'openrouter';
11062 } else {
11063 if (!class_exists('MxChat_Model_Catalog')) {
11064 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
11065 }
11066 $allowed_models = MxChat_Model_Catalog::chat_model_ids();
11067
11068 if (in_array($input['model'], $allowed_models)) {
11069 $new_input['model'] = sanitize_text_field($input['model']);
11070 } else {
11071 // Fallback for any deprecated model
11072 $new_input['model'] = 'gpt-5.6-sol';
11073 }
11074 }
11075 }
11076
11077 if (isset($input['openrouter_selected_model'])) {
11078 // Just sanitize it, don't validate against a whitelist
11079 $new_input['openrouter_selected_model'] = sanitize_text_field($input['openrouter_selected_model']);
11080 }
11081
11082 // ADD THIS:
11083 if (isset($input['openrouter_selected_model_name'])) {
11084 $new_input['openrouter_selected_model_name'] = sanitize_text_field($input['openrouter_selected_model_name']);
11085 }
11086
11087 if (isset($input['openrouter_api_key'])) {
11088 $new_input['openrouter_api_key'] = sanitize_text_field($input['openrouter_api_key']);
11089 }
11090
11091 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, Azure OpenAI, etc.
11092 if (isset($input['custom_provider_base_url'])) {
11093 $new_input['custom_provider_base_url'] = esc_url_raw(rtrim(trim((string) $input['custom_provider_base_url']), '/'));
11094 }
11095 if (isset($input['custom_provider_api_key'])) {
11096 $new_input['custom_provider_api_key'] = sanitize_text_field($input['custom_provider_api_key']);
11097 }
11098 if (isset($input['custom_provider_model'])) {
11099 $new_input['custom_provider_model'] = sanitize_text_field($input['custom_provider_model']);
11100 }
11101 if (isset($input['custom_provider_auth_scheme'])) {
11102 $scheme = sanitize_text_field($input['custom_provider_auth_scheme']);
11103 $new_input['custom_provider_auth_scheme'] = in_array($scheme, array('bearer', 'api-key'), true) ? $scheme : 'bearer';
11104 }
11105 if (isset($input['custom_provider_for_embeddings'])) {
11106 $new_input['custom_provider_for_embeddings'] = ($input['custom_provider_for_embeddings'] === 'on') ? 'on' : 'off';
11107 }
11108 if (isset($input['custom_provider_for_images'])) {
11109 $new_input['custom_provider_for_images'] = ($input['custom_provider_for_images'] === 'on') ? 'on' : 'off';
11110 }
11111 if (isset($input['custom_provider_embedding_model'])) {
11112 $new_input['custom_provider_embedding_model'] = sanitize_text_field($input['custom_provider_embedding_model']);
11113 }
11114 if (isset($input['custom_provider_api_version'])) {
11115 $new_input['custom_provider_api_version'] = sanitize_text_field($input['custom_provider_api_version']);
11116 }
11117
11118
11119
11120 if (isset($input['woocommerce_consumer_key'])) {
11121 $new_input['woocommerce_consumer_key'] = sanitize_text_field($input['woocommerce_consumer_key']);
11122 }
11123
11124 if (isset($input['woocommerce_consumer_secret'])) {
11125 $new_input['woocommerce_consumer_secret'] = sanitize_text_field($input['woocommerce_consumer_secret']);
11126 }
11127
11128
11129 // Sanitize link_target_toggle
11130 if (isset($input['link_target_toggle'])) {
11131 $new_input['link_target_toggle'] = $input['link_target_toggle'] === 'on' ? 'on' : 'off';
11132 }
11133
11134 // Sanitize Loops API Key
11135 if (isset($input['loops_api_key'])) {
11136 $new_input['loops_api_key'] = sanitize_text_field($input['loops_api_key']);
11137 }
11138
11139 if (isset($input['chat_persistence_toggle'])) {
11140 $new_input['chat_persistence_toggle'] = $input['chat_persistence_toggle'] === 'on' ? 'on' : 'off';
11141 }
11142
11143 // No else clause: an absent key stays absent, so the front-end default ('on') applies
11144 // and the rebuild never strips a saved 'off' (autosave passes the full options array back through here).
11145 if (isset($input['print_button_enabled'])) {
11146 $new_input['print_button_enabled'] = $input['print_button_enabled'] === 'on' ? 'on' : 'off';
11147 }
11148
11149 // plan ac2e81 — "Start new chat" toggle (default OFF) + editable label.
11150 // No else on the toggle: an absent key stays absent so the front-end '?? off'
11151 // default applies, and the full-array autosave rebuild preserves a saved value.
11152 if (isset($input['reset_chat_enabled'])) {
11153 $new_input['reset_chat_enabled'] = $input['reset_chat_enabled'] === 'on' ? 'on' : 'off';
11154 }
11155
11156 if (isset($input['reset_chat_label'])) {
11157 $new_input['reset_chat_label'] = sanitize_text_field($input['reset_chat_label']);
11158 }
11159
11160 if (isset($input['popular_question_1'])) {
11161 $new_input['popular_question_1'] = sanitize_text_field($input['popular_question_1']);
11162 }
11163
11164 if (isset($input['popular_question_2'])) {
11165 $new_input['popular_question_2'] = sanitize_text_field($input['popular_question_2']);
11166 }
11167
11168 if (isset($input['popular_question_3'])) {
11169 $new_input['popular_question_3'] = sanitize_text_field($input['popular_question_3']);
11170 }
11171
11172 if (isset($input['additional_popular_questions']) && is_array($input['additional_popular_questions'])) {
11173 $new_input['additional_popular_questions'] = array_map('sanitize_text_field', $input['additional_popular_questions']);
11174 }
11175
11176 // Sanitize Loops Mailing List
11177 if (isset($input['loops_mailing_list'])) {
11178 $new_input['loops_mailing_list'] = sanitize_text_field($input['loops_mailing_list']);
11179 }
11180
11181 // Sanitize Triggered Phrase Response
11182 if (isset($input['triggered_phrase_response'])) {
11183 $new_input['triggered_phrase_response'] = wp_kses_post($input['triggered_phrase_response']);
11184 }
11185 if (isset($input['email_capture_response'])) {
11186 $new_input['email_capture_response'] = wp_kses_post($input['email_capture_response']);
11187 }
11188
11189 // Sanitize Brave Search Settings
11190 if (isset($input['brave_api_key'])) {
11191 $new_input['brave_api_key'] = sanitize_text_field($input['brave_api_key']);
11192 }
11193
11194 if (isset($input['brave_image_count'])) {
11195 $image_count = intval($input['brave_image_count']);
11196 $new_input['brave_image_count'] = ($image_count >=1 && $image_count <=6) ? $image_count : 4;
11197 }
11198
11199 if (isset($input['brave_safe_search'])) {
11200 $allowed = array('strict', 'off');
11201 $new_input['brave_safe_search'] = in_array($input['brave_safe_search'], $allowed, true) ? $input['brave_safe_search'] : 'strict';
11202 }
11203
11204 if (isset($input['brave_news_count'])) {
11205 $news_count = intval($input['brave_news_count']);
11206 $new_input['brave_news_count'] = ($news_count >=1 && $news_count <=10) ? $news_count : 3;
11207 }
11208
11209 if (isset($input['brave_country'])) {
11210 $new_input['brave_country'] = sanitize_text_field($input['brave_country']);
11211 }
11212
11213 if (isset($input['brave_language'])) {
11214 $new_input['brave_language'] = sanitize_text_field($input['brave_language']);
11215 }
11216
11217 if (isset($input['chat_toolbar_toggle'])) {
11218 $new_input['chat_toolbar_toggle'] = $input['chat_toolbar_toggle'] === 'on' ? 'on' : 'off';
11219 }
11220
11221 // Sanitize PDF upload button toggle
11222 if (isset($input['show_pdf_upload_button'])) {
11223 $new_input['show_pdf_upload_button'] = $input['show_pdf_upload_button'] === 'on' ? 'on' : 'off';
11224 } else {
11225 $new_input['show_pdf_upload_button'] = 'off'; // If checkbox is unchecked
11226 }
11227
11228 // Sanitize Word upload button toggle
11229 if (isset($input['show_word_upload_button'])) {
11230 $new_input['show_word_upload_button'] = $input['show_word_upload_button'] === 'on' ? 'on' : 'off';
11231 } else {
11232 $new_input['show_word_upload_button'] = 'off'; // If checkbox is unchecked
11233 }
11234
11235 if (isset($input['pdf_intent_trigger_text'])) {
11236 $new_input['pdf_intent_trigger_text'] = sanitize_text_field($input['pdf_intent_trigger_text']);
11237 }
11238
11239 if (isset($input['pdf_intent_success_text'])) {
11240 $new_input['pdf_intent_success_text'] = sanitize_text_field($input['pdf_intent_success_text']);
11241 }
11242
11243 if (isset($input['pdf_intent_error_text'])) {
11244 $new_input['pdf_intent_error_text'] = sanitize_text_field($input['pdf_intent_error_text']);
11245 }
11246
11247 if (isset($input['pdf_max_pages'])) {
11248 $new_input['pdf_max_pages'] = intval($input['pdf_max_pages']);
11249 if ($new_input['pdf_max_pages'] < 1 || $new_input['pdf_max_pages'] > 69) {
11250 $new_input['pdf_max_pages'] = 69; // Default to 69 if out of range
11251 }
11252 }
11253
11254 if (isset($input['live_agent_webhook_url'])) {
11255 $new_input['live_agent_webhook_url'] = esc_url_raw($input['live_agent_webhook_url']);
11256 }
11257 if (isset($input['live_agent_secret_key'])) {
11258 $new_input['live_agent_secret_key'] = sanitize_text_field($input['live_agent_secret_key']);
11259 }
11260
11261 // Live Agent Integration
11262 if (isset($input['live_agent_bot_token'])) {
11263 $new_input['live_agent_bot_token'] = sanitize_text_field($input['live_agent_bot_token']);
11264 }
11265
11266 if (isset($input['live_agent_shared_channel'])) {
11267 $new_input['live_agent_shared_channel'] = sanitize_text_field($input['live_agent_shared_channel']);
11268 }
11269
11270 // Default-OFF toggle: absent key stays absent (unchecked box = off).
11271 if (isset($input['live_agent_archive_on_end_toggle'])) {
11272 $new_input['live_agent_archive_on_end_toggle'] = ($input['live_agent_archive_on_end_toggle'] === 'on') ? 'on' : 'off';
11273 }
11274
11275 if (isset($input['live_agent_user_ids'])) {
11276 $new_input['live_agent_user_ids'] = sanitize_textarea_field($input['live_agent_user_ids']);
11277 }
11278
11279 if (isset($input['live_agent_status'])) {
11280 $new_input['live_agent_status'] = ($input['live_agent_status'] === 'on') ? 'on' : 'off';
11281 }
11282 if (isset($input['live_agent_away_message'])) {
11283 $new_input['live_agent_away_message'] = sanitize_textarea_field($input['live_agent_away_message']);
11284 }
11285 if (isset($input['live_agent_notification_message'])) {
11286 $new_input['live_agent_notification_message'] = sanitize_textarea_field($input['live_agent_notification_message']);
11287 }
11288
11289 // Telegram Integration
11290 if (isset($input['telegram_status'])) {
11291 $new_input['telegram_status'] = ($input['telegram_status'] === 'on') ? 'on' : 'off';
11292 }
11293 if (isset($input['telegram_bot_token'])) {
11294 $new_input['telegram_bot_token'] = sanitize_text_field($input['telegram_bot_token']);
11295 }
11296 if (isset($input['telegram_group_id'])) {
11297 $new_input['telegram_group_id'] = sanitize_text_field($input['telegram_group_id']);
11298 }
11299 if (isset($input['telegram_webhook_secret'])) {
11300 $new_input['telegram_webhook_secret'] = sanitize_text_field($input['telegram_webhook_secret']);
11301 }
11302 if (isset($input['telegram_notification_message'])) {
11303 $new_input['telegram_notification_message'] = sanitize_textarea_field($input['telegram_notification_message']);
11304 }
11305 if (isset($input['telegram_away_message'])) {
11306 $new_input['telegram_away_message'] = sanitize_textarea_field($input['telegram_away_message']);
11307 }
11308
11309 // Webhook Handoff (plan d88e22)
11310 if (isset($input['webhook_handoff_status'])) {
11311 $new_input['webhook_handoff_status'] = ($input['webhook_handoff_status'] === 'on') ? 'on' : 'off';
11312 }
11313 if (isset($input['webhook_handoff_url'])) {
11314 // https only — esc_url_raw with an https-only protocol list stores ''
11315 // for anything else, so a non-https URL can never persist.
11316 $new_input['webhook_handoff_url'] = esc_url_raw(trim((string) $input['webhook_handoff_url']), array('https'));
11317 }
11318 if (isset($input['webhook_handoff_secret'])) {
11319 $new_input['webhook_handoff_secret'] = sanitize_text_field($input['webhook_handoff_secret']);
11320 }
11321 if (isset($input['webhook_handoff_notification_message'])) {
11322 $new_input['webhook_handoff_notification_message'] = sanitize_textarea_field($input['webhook_handoff_notification_message']);
11323 }
11324 if (isset($input['webhook_handoff_away_message'])) {
11325 $new_input['webhook_handoff_away_message'] = sanitize_textarea_field($input['webhook_handoff_away_message']);
11326 }
11327
11328 // Sanitize script loading strategy
11329 if (isset($input['script_loading_strategy'])) {
11330 $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction');
11331 $new_input['script_loading_strategy'] = in_array($input['script_loading_strategy'], $allowed_strategies)
11332 ? $input['script_loading_strategy']
11333 : 'default';
11334 }
11335
11336 // Sanitize debug mode
11337 if (isset($input['debug_mode'])) {
11338 $new_input['debug_mode'] = ($input['debug_mode'] === 'on') ? 'on' : 'off';
11339 }
11340
11341 // Content Generator Settings
11342 if (isset($input['content_model'])) {
11343 $new_input['content_model'] = sanitize_text_field($input['content_model']);
11344 }
11345 if (isset($input['content_image_model'])) {
11346 $new_input['content_image_model'] = sanitize_text_field($input['content_image_model']);
11347 }
11348 if (isset($input['content_image_quality'])) {
11349 $q = sanitize_text_field($input['content_image_quality']);
11350 $new_input['content_image_quality'] = in_array($q, array('auto', 'low', 'medium', 'high'), true) ? $q : 'auto';
11351 }
11352 if (isset($input['content_enable_images'])) {
11353 $new_input['content_enable_images'] = ($input['content_enable_images'] === 'on') ? 'on' : 'off';
11354 }
11355 if (isset($input['content_use_placeholders'])) {
11356 $new_input['content_use_placeholders'] = ($input['content_use_placeholders'] === 'on') ? 'on' : 'off';
11357 }
11358 if (isset($input['content_internal_linking'])) {
11359 $new_input['content_internal_linking'] = ($input['content_internal_linking'] === 'on') ? 'on' : 'off';
11360 }
11361 if (isset($input['content_tool_use'])) {
11362 $new_input['content_tool_use'] = ($input['content_tool_use'] === 'on') ? 'on' : 'off';
11363 }
11364 if (isset($input['content_image_count'])) {
11365 $new_input['content_image_count'] = (string) max(1, min(5, (int) $input['content_image_count']));
11366 }
11367
11368 // SEO Optimize toggle fields
11369 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) {
11370 if (isset($input[$seo_key])) {
11371 $new_input[$seo_key] = ($input[$seo_key] === 'on') ? 'on' : 'off';
11372 }
11373 }
11374
11375 // Preserve content generator settings when saving from main settings page
11376 // (where content fields are not in the form submission)
11377 $existing = get_option('mxchat_options', array());
11378 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) {
11379 if (!isset($new_input[$key]) && isset($existing[$key])) {
11380 $new_input[$key] = $existing[$key];
11381 }
11382 }
11383
11384 return $new_input;
11385 }
11386
11387 /**
11388 * Log a debug message if debug mode is enabled
11389 *
11390 * @param string $type The type of log entry (settings_save, api_error, activation, etc.)
11391 * @param string $message The log message
11392 * @param array $data Optional additional data to log
11393 * @return bool Whether the message was logged
11394 */
11395 public static function mxchat_log_debug( $type, $message, $data = array() ) {
11396 $options = get_option( 'mxchat_options', array() );
11397
11398 // Check if debug mode is enabled
11399 if ( ! isset( $options['debug_mode'] ) || $options['debug_mode'] !== 'on' ) {
11400 return false;
11401 }
11402
11403 // Get current log
11404 $log = get_option( 'mxchat_debug_log', array() );
11405 if ( ! is_array( $log ) ) {
11406 $log = array();
11407 }
11408
11409 // Add new entry
11410 $entry = array(
11411 'time' => current_time( 'Y-m-d H:i:s' ),
11412 'type' => sanitize_key( $type ),
11413 'message' => sanitize_text_field( $message ),
11414 );
11415
11416 if ( ! empty( $data ) ) {
11417 $entry['data'] = $data;
11418 }
11419
11420 // Add to beginning of array (newest first)
11421 array_unshift( $log, $entry );
11422
11423 // Keep only last 100 entries
11424 if ( count( $log ) > 100 ) {
11425 $log = array_slice( $log, 0, 100 );
11426 }
11427
11428 // Save log
11429 update_option( 'mxchat_debug_log', $log, false );
11430
11431 return true;
11432 }
11433
11434 /**
11435 * Get the debug log entries
11436 *
11437 * @return array The debug log entries
11438 */
11439 public static function mxchat_get_debug_log() {
11440 $log = get_option( 'mxchat_debug_log', array() );
11441 return is_array( $log ) ? $log : array();
11442 }
11443
11444 /**
11445 * Clear the debug log
11446 *
11447 * @return bool Whether the log was cleared
11448 */
11449 public static function mxchat_clear_debug_log() {
11450 return delete_option( 'mxchat_debug_log' );
11451 }
11452
11453 /**
11454 * Export settings as JSON with masked API keys
11455 *
11456 * @return array The sanitized settings array
11457 */
11458 public static function mxchat_export_settings() {
11459 $options = get_option( 'mxchat_options', array() );
11460
11461 if ( ! is_array( $options ) ) {
11462 return array();
11463 }
11464
11465 // List of API key fields to mask
11466 $api_key_fields = array(
11467 'api_key',
11468 'xai_api_key',
11469 'claude_api_key',
11470 'deepseek_api_key',
11471 'voyage_api_key',
11472 'gemini_api_key',
11473 'openrouter_api_key',
11474 'loops_api_key',
11475 'brave_api_key',
11476 'live_agent_secret_key',
11477 'live_agent_bot_token',
11478 'telegram_bot_token',
11479 'telegram_webhook_secret',
11480 'woocommerce_consumer_key',
11481 'woocommerce_consumer_secret',
11482 );
11483
11484 // Mask API keys (show only last 4 characters)
11485 foreach ( $api_key_fields as $field ) {
11486 if ( isset( $options[ $field ] ) && ! empty( $options[ $field ] ) ) {
11487 $value = $options[ $field ];
11488 if ( strlen( $value ) > 4 ) {
11489 $options[ $field ] = str_repeat( '*', strlen( $value ) - 4 ) . substr( $value, -4 );
11490 } else {
11491 $options[ $field ] = '****';
11492 }
11493 }
11494 }
11495
11496 // Add metadata
11497 $export = array(
11498 'plugin_version' => defined( 'MXCHAT_VERSION' ) ? MXCHAT_VERSION : 'unknown',
11499 'export_date' => current_time( 'Y-m-d H:i:s' ),
11500 'wordpress_version' => get_bloginfo( 'version' ),
11501 'php_version' => phpversion(),
11502 'settings' => $options,
11503 );
11504
11505 return $export;
11506 }
11507
11508 /**
11509 * Reset all settings to defaults
11510 *
11511 * @return bool Whether the reset was successful
11512 */
11513 public static function mxchat_reset_all_settings() {
11514 // Delete main options
11515 $deleted = delete_option( 'mxchat_options' );
11516
11517 // Also clear the debug log
11518 delete_option( 'mxchat_debug_log' );
11519
11520 // Log the reset (will create new log since we just cleared it)
11521 // We need to temporarily enable debug mode to log this
11522 $temp_options = array( 'debug_mode' => 'on' );
11523 update_option( 'mxchat_options', $temp_options );
11524
11525 self::mxchat_log_debug( 'reset', 'All settings have been reset to defaults' );
11526
11527 // Now delete again to trigger re-initialization
11528 delete_option( 'mxchat_options' );
11529
11530 return $deleted;
11531 }
11532
11533 // Method to append the chatbot to the body
11534 public function mxchat_append_chatbot_to_body() {
11535 $options = get_option('mxchat_options');
11536 if (isset($options['append_to_body']) && $options['append_to_body'] === 'on') {
11537 echo do_shortcode('[mxchat_chatbot floating="yes"]');
11538 }
11539 }
11540
11541
11542
11543
11544
11545 private function mxchat_fetch_loops_mailing_lists($api_key) {
11546 $url = 'https://app.loops.so/api/v1/lists';
11547 $response = wp_remote_get($url, array(
11548 'headers' => array(
11549 'Authorization' => 'Bearer ' . $api_key,
11550 'Content-Type' => 'application/json'
11551 )
11552 ));
11553
11554 if (is_wp_error($response)) {
11555 return array();
11556 }
11557
11558 $body = wp_remote_retrieve_body($response);
11559 $lists = json_decode($body, true);
11560
11561 return isset($lists) && is_array($lists) ? $lists : array();
11562 }
11563
11564 function mxchat_calculate_cosine_similarity($vec1, $vec2) {
11565 if (empty($vec1) || empty($vec2)) {
11566 return 0.0;
11567 }
11568
11569 $dot_product = 0.0;
11570 $norm_a = 0.0;
11571 $norm_b = 0.0;
11572
11573 for ($i = 0; $i < count($vec1); $i++) {
11574 $dot_product += $vec1[$i] * $vec2[$i];
11575 $norm_a += pow($vec1[$i], 2);
11576 $norm_b += pow($vec2[$i], 2);
11577 }
11578
11579 if ($norm_a == 0.0 || $norm_b == 0.0) {
11580 return 0.0;
11581 } else {
11582 return $dot_product / (sqrt($norm_a) * sqrt($norm_b));
11583 }
11584 }
11585
11586 /**
11587 * Validates nonce and deletes all chat prompts
11588 */
11589 public function mxchat_handle_delete_all_prompts() {
11590 //error_log('=== DELETE ALL DEBUG START ===');
11591
11592 // Verify nonce
11593 if (!isset($_POST['mxchat_delete_all_prompts_nonce']) || !wp_verify_nonce($_POST['mxchat_delete_all_prompts_nonce'], 'mxchat_delete_all_prompts_action')) {
11594 //error_log('DEBUG: Nonce verification failed');
11595 wp_die(__('Nonce verification failed.', 'mxchat'));
11596 }
11597
11598 // Check permissions
11599 if (!current_user_can('manage_options')) {
11600 //error_log('DEBUG: Permission check failed');
11601 wp_die(__('You do not have sufficient permissions to delete all prompts.', 'mxchat'));
11602 }
11603
11604 // Get bot_id and content type filter from POST data
11605 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
11606 $content_type_filter = isset($_POST['content_type_filter']) ? sanitize_text_field($_POST['content_type_filter']) : '';
11607
11608 $success = true;
11609 $error_messages = array();
11610
11611 // Get bot-specific Pinecone configuration
11612 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
11613 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
11614
11615 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
11616
11617 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
11618 // Delete from Pinecone (with optional content type filter)
11619 $result = $pinecone_manager->mxchat_delete_all_from_pinecone($pinecone_options, $content_type_filter);
11620
11621 if (!$result['success']) {
11622 $success = false;
11623 $error_messages[] = $result['message'];
11624 }
11625
11626 } else {
11627 // Delete from WordPress database
11628 global $wpdb;
11629 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
11630
11631 // Build WHERE conditions
11632 $where_clauses = array();
11633 $where_values = array();
11634
11635 // Bot filter
11636 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
11637 $where_clauses[] = 'bot_id = %s';
11638 $where_values[] = $bot_id;
11639 }
11640
11641 // Content type filter
11642 if (!empty($content_type_filter)) {
11643 $where_clauses[] = 'content_type = %s';
11644 $where_values[] = $content_type_filter;
11645 }
11646
11647 if (!empty($where_clauses)) {
11648 $where_sql = implode(' AND ', $where_clauses);
11649 $result = $wpdb->query($wpdb->prepare("DELETE FROM {$table_name} WHERE {$where_sql}", $where_values));
11650 } else {
11651 // No filters — delete all
11652 $result = $wpdb->query("DELETE FROM {$table_name}");
11653 }
11654
11655 if ($result === false) {
11656 $success = false;
11657 $error_messages[] = 'Failed to delete from WordPress database';
11658 }
11659 }
11660
11661 // Every media entry in scope is gone: their auto-sync signatures go too,
11662 // or each file is skipped as "unchanged" on its next save (b0ae54).
11663 if ($success && (empty($content_type_filter) || $content_type_filter === 'attachment')) {
11664 MxChat_Utils::forget_all_attachment_signatures($bot_id);
11665 }
11666
11667 // Redirect back with a success message and bot_id
11668 $redirect_url = add_query_arg(array(
11669 'page' => 'mxchat-prompts',
11670 'bot_id' => $bot_id,
11671 'all_deleted' => $success ? 'true' : 'false'
11672 ), admin_url('admin.php'));
11673
11674 wp_safe_redirect($redirect_url);
11675 exit;
11676 }
11677
11678
11679
11680 /**
11681 * Handles deletion prompt with nonce validation
11682 */
11683 public function mxchat_handle_delete_prompt() {
11684 // Sanitize and validate nonce
11685 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
11686 if (empty($nonce) || !wp_verify_nonce($nonce, 'mxchat_delete_prompt_nonce')) {
11687 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
11688 }
11689
11690 // Check permissions
11691 if (!current_user_can('manage_options')) {
11692 wp_die(esc_html__('You do not have sufficient permissions to delete prompts.', 'mxchat'));
11693 }
11694
11695 // Get ID and source parameters
11696 $id = isset($_GET['id']) ? sanitize_text_field($_GET['id']) : '';
11697 $source = isset($_GET['source']) ? sanitize_text_field($_GET['source']) : '';
11698
11699 if (empty($id)) {
11700 wp_die(esc_html__('Invalid prompt ID.', 'mxchat'));
11701 }
11702
11703 $success = false;
11704 $error_message = '';
11705
11706 // Resolve per-bot Pinecone settings — the global option is the wrong
11707 // index entirely when the row belongs to a bot with its own host
11708 $bot_id = isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default';
11709 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
11710 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
11711 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
11712
11713 // If source is not specified, determine based on Pinecone configuration
11714 if (empty($source)) {
11715 $source = $use_pinecone ? 'pinecone' : 'wordpress';
11716 }
11717
11718 if ($source === 'pinecone' || $use_pinecone) {
11719 //error_log('[MXCHAT-DELETE] Deleting from Pinecone, ID: ' . $id);
11720
11721 // Handle Pinecone deletion
11722 if (empty($pinecone_options['mxchat_pinecone_host']) ||
11723 empty($pinecone_options['mxchat_pinecone_api_key'])) {
11724 wp_die(esc_html__('Pinecone configuration is missing.', 'mxchat'));
11725 }
11726
11727 // Delete from Pinecone using the vector ID directly
11728 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
11729 $id,
11730 $pinecone_options['mxchat_pinecone_api_key'],
11731 $pinecone_options['mxchat_pinecone_host'],
11732 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
11733 );
11734 if ($result['success']) {
11735 $success = true;
11736
11737 // Remove from vector cache
11738 $pinecone_manager->mxchat_remove_from_pinecone_vector_cache($id);
11739 $pinecone_manager->mxchat_remove_from_processed_content_caches($id);
11740
11741 set_transient('mxchat_admin_notice_success',
11742 esc_html__('Vector deleted successfully from Pinecone.', 'mxchat'), 30);
11743 } else {
11744 $error_message = $result['message'];
11745 set_transient('mxchat_admin_notice_error',
11746 esc_html__('Failed to delete from Pinecone: ', 'mxchat') . esc_html($error_message), 30);
11747 }
11748 } else {
11749 //error_log('[MXCHAT-DELETE] Deleting from WordPress database, ID: ' . $id);
11750
11751 // Handle WordPress database deletion
11752 global $wpdb;
11753 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
11754
11755 // Clear cache and delete prompt
11756 wp_cache_delete('prompt_' . $id, 'mxchat_prompts');
11757
11758 $result = $wpdb->delete(
11759 $table_name,
11760 array('id' => intval($id)),
11761 array('%d')
11762 );
11763
11764 if ($result !== false) {
11765 $success = true;
11766 set_transient('mxchat_admin_notice_success',
11767 esc_html__('Entry deleted successfully.', 'mxchat'), 30);
11768 } else {
11769 set_transient('mxchat_admin_notice_error',
11770 esc_html__('Failed to delete entry from database.', 'mxchat'), 30);
11771 }
11772 }
11773
11774 // Redirect back to the prompts page
11775 wp_safe_redirect(add_query_arg(
11776 array(
11777 'page' => 'mxchat-prompts',
11778 'deleted' => $success ? 'true' : 'false'
11779 ),
11780 admin_url('admin.php')
11781 ));
11782 exit;
11783 }
11784
11785
11786
11787
11788 /**
11789 * Averages multiple vectors into a single vector
11790 */
11791 private function mxchat_average_vectors($vectors) {
11792 $vector_length = count($vectors[0]);
11793 $sum_vector = array_fill(0, $vector_length, 0);
11794
11795 foreach ($vectors as $vector) {
11796 for ($i = 0; $i < $vector_length; $i++) {
11797 $sum_vector[$i] += $vector[$i];
11798 }
11799 }
11800
11801 // Divide each component by the number of vectors to get the average
11802 $num_vectors = count($vectors);
11803 for ($i = 0; $i < $vector_length; $i++) {
11804 $sum_vector[$i] /= $num_vectors;
11805 }
11806
11807 return $sum_vector;
11808 }
11809
11810
11811
11812
11813 /**
11814 * Generates embeddings from input text for MXChat
11815 */
11816 public function mxchat_generate_embedding($text) {
11817 // Enable detailed logging for debugging
11818 //error_log('[MXCHAT-EMBED] Starting embedding generation. Text length: ' . strlen($text) . ' bytes');
11819 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
11820
11821 $options = get_option('mxchat_options');
11822
11823 // Custom Provider embeddings first, as the chat and the knowledge
11824 // importers do (plan 418afd): a phrase embedded with another model
11825 // can never match a question embedded with the custom one. Returns
11826 // the vector, or the error string this function returns on failure.
11827 if (is_array($options) && isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
11828 return MxChat_Utils::generate_embedding_custom($text, $options);
11829 }
11830
11831 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
11832 //error_log('[MXCHAT-EMBED] Selected embedding model: ' . $selected_model);
11833
11834 // Determine provider and endpoint
11835 if (strpos($selected_model, 'voyage') === 0) {
11836 $api_key = $options['voyage_api_key'] ?? '';
11837 $endpoint = 'https://api.voyageai.com/v1/embeddings';
11838 $provider_name = 'Voyage AI';
11839 //error_log('[MXCHAT-EMBED] Using Voyage AI API');
11840 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
11841 $api_key = $options['gemini_api_key'] ?? '';
11842 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
11843 $provider_name = 'Google Gemini';
11844 //error_log('[MXCHAT-EMBED] Using Google Gemini API');
11845 } else {
11846 $api_key = $options['api_key'] ?? '';
11847 $endpoint = 'https://api.openai.com/v1/embeddings';
11848 $provider_name = 'OpenAI';
11849 //error_log('[MXCHAT-EMBED] Using OpenAI API');
11850 }
11851
11852 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
11853
11854 if (empty($api_key)) {
11855 $error_message = sprintf('Missing %s API key. Please configure your API key in the MxChat settings.', $provider_name);
11856 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
11857 return $error_message;
11858 }
11859
11860 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
11861 $estimated_tokens = ceil(str_word_count($text) / 0.75);
11862 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
11863
11864 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
11865 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
11866 // Consider truncating text here
11867 }
11868
11869 // Prepare request body based on provider
11870 if (strpos($selected_model, 'gemini-embedding') === 0) {
11871 // Gemini API format
11872 $request_body = array(
11873 'model' => 'models/' . $selected_model,
11874 'content' => array(
11875 'parts' => array(
11876 array('text' => $text)
11877 )
11878 )
11879 );
11880
11881 // Set output dimensionality to 1536 for consistency with other models
11882 $request_body['outputDimensionality'] = 1536;
11883 } else {
11884 // OpenAI/Voyage API format
11885 $request_body = array(
11886 'model' => $selected_model,
11887 'input' => $text
11888 );
11889
11890 // Add output_dimension for voyage-3-large model
11891 if ($selected_model === 'voyage-3-large') {
11892 $request_body['output_dimension'] = 2048;
11893 }
11894 }
11895
11896 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
11897
11898 // Prepare headers based on provider
11899 if (strpos($selected_model, 'gemini-embedding') === 0) {
11900 // Gemini uses API key as query parameter
11901 $endpoint .= '?key=' . $api_key;
11902 $headers = array(
11903 'Content-Type' => 'application/json'
11904 );
11905 } else {
11906 // OpenAI/Voyage use Bearer token
11907 $headers = array(
11908 'Authorization' => 'Bearer ' . $api_key,
11909 'Content-Type' => 'application/json'
11910 );
11911 }
11912
11913 // Make API request
11914 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
11915 $response = wp_remote_post($endpoint, array(
11916 'body' => wp_json_encode($request_body),
11917 'headers' => $headers,
11918 'timeout' => 60 // Increased timeout for large inputs
11919 ));
11920
11921 // Handle wp_remote_post errors
11922 if (is_wp_error($response)) {
11923 $error_message = $response->get_error_message();
11924 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
11925 return 'Connection error: ' . $error_message;
11926 }
11927
11928 // Get and check HTTP response code
11929 $http_code = wp_remote_retrieve_response_code($response);
11930 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
11931
11932 if ($http_code !== 200) {
11933 $error_body = wp_remote_retrieve_body($response);
11934 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
11935
11936 // Try to parse error for more details
11937 $error_json = json_decode($error_body, true);
11938 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
11939 $error_type = $error_json['error']['type'] ?? 'unknown';
11940 $error_message = $error_json['error']['message'] ?? 'No message';
11941 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
11942 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
11943
11944 // Customize error message for common API errors
11945 if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
11946 $error_message = sprintf('Invalid %s API key. Please check your API key in the MxChat settings.', $provider_name);
11947 } elseif ($error_type === 'authentication_error') {
11948 $error_message = sprintf('%s authentication failed. Please verify your API key in the MxChat settings.', $provider_name);
11949 }
11950
11951 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
11952 return $error_message;
11953 }
11954
11955 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding", $http_code);
11956 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
11957 return $error_message;
11958 }
11959
11960 // Parse response body
11961 $response_body = wp_remote_retrieve_body($response);
11962 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
11963
11964 $response_data = json_decode($response_body, true);
11965
11966 if (json_last_error() !== JSON_ERROR_NONE) {
11967 $error = json_last_error_msg();
11968 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
11969 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
11970 return "Failed to parse API response: $error";
11971 }
11972
11973 // Handle different response formats based on provider
11974 if (strpos($selected_model, 'gemini-embedding') === 0) {
11975 // Gemini API response format
11976 if (isset($response_data['embedding']['values'])) {
11977 $embedding_dimensions = count($response_data['embedding']['values']);
11978 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
11979
11980 // Check if embedding dimensions are as expected (should be 1536)
11981 if ($embedding_dimensions !== 1536) {
11982 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
11983 }
11984
11985 MxChat_Utils::stamp_active_embedding_model($selected_model);
11986 return $response_data['embedding']['values'];
11987 } else {
11988 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
11989 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
11990
11991 if (isset($response_data['error'])) {
11992 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
11993 //error_log('[MXCHAT-EMBED] ' . $error_message);
11994 return $error_message;
11995 }
11996
11997 $error_message = "Invalid Gemini API response format: No embedding found";
11998 //error_log('[MXCHAT-EMBED] ' . $error_message);
11999 return $error_message;
12000 }
12001 } else {
12002 // OpenAI/Voyage API response format
12003 if (isset($response_data['data'][0]['embedding'])) {
12004 $embedding_dimensions = count($response_data['data'][0]['embedding']);
12005 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
12006
12007 // Check if embedding dimensions are as expected
12008 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
12009 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
12010 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
12011 }
12012
12013 MxChat_Utils::stamp_active_embedding_model($selected_model);
12014 return $response_data['data'][0]['embedding'];
12015 } else {
12016 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
12017 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
12018
12019 if (isset($response_data['error'])) {
12020 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
12021 //error_log('[MXCHAT-EMBED] ' . $error_message);
12022 return $error_message;
12023 }
12024
12025 $error_message = "Invalid API response format: No embedding found";
12026 //error_log('[MXCHAT-EMBED] ' . $error_message);
12027 return $error_message;
12028 }
12029 }
12030 }
12031
12032
12033
12034 }
12035 ?>
12036