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

11,890 lines 527.7 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 $sanitized['mxchat_use_pinecone'] = isset($input['mxchat_use_pinecone']) ? '1' : '0';
986 $sanitized['mxchat_pinecone_api_key'] = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
987 $sanitized['mxchat_pinecone_host'] = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
988 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
989 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
990
991 // d0cae1: this function REBUILDS the array, so the topK key must be
992 // carried here or any form save strips it. Clamp mirrors the read site.
993 $top_k = absint($input['mxchat_pinecone_top_k'] ?? 50);
994 $sanitized['mxchat_pinecone_top_k'] = (string) (($top_k >= 1 && $top_k <= 1000) ? $top_k : 50);
995
996 // Remove https:// from host if present
997 $sanitized['mxchat_pinecone_host'] = str_replace(['https://', 'http://'], '', $sanitized['mxchat_pinecone_host']);
998
999 // Index type (plan 362c31). The document index is opt-in, and its host
1000 // must have passed Check index (or come from Create index for me) before
1001 // the choice sticks — a classic host routed through the documents API
1002 // answers 400 on every query. The verified host is carried from the
1003 // stored option; only the AJAX handlers set it, never the form.
1004 $existing = get_option('mxchat_pinecone_addon_options', array());
1005 $existing = is_array($existing) ? $existing : array();
1006 $verified_host = (string) ($existing['mxchat_pinecone_docs_verified_host'] ?? '');
1007 $index_type = (($input['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector';
1008 $host_for_check = strtolower(trim($sanitized['mxchat_pinecone_host'], '/'));
1009 if ($index_type === 'document' && ($host_for_check === '' || $host_for_check !== strtolower($verified_host))) {
1010 $index_type = 'vector';
1011 if (function_exists('add_settings_error')) {
1012 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'));
1013 }
1014 }
1015 $sanitized['mxchat_pinecone_index_type'] = $index_type;
1016 $sanitized['mxchat_pinecone_docs_verified_host'] = $verified_host;
1017 $vector_host = str_replace(['https://', 'http://'], '', sanitize_text_field($input['mxchat_pinecone_vector_host'] ?? ($existing['mxchat_pinecone_vector_host'] ?? '')));
1018 $sanitized['mxchat_pinecone_vector_host'] = trim($vector_host, '/');
1019 $sanitized['mxchat_pinecone_vector_index'] = sanitize_text_field($input['mxchat_pinecone_vector_index'] ?? ($existing['mxchat_pinecone_vector_index'] ?? ''));
1020 $docs_cloud = sanitize_key($input['mxchat_pinecone_docs_cloud'] ?? ($existing['mxchat_pinecone_docs_cloud'] ?? 'aws'));
1021 $sanitized['mxchat_pinecone_docs_cloud'] = in_array($docs_cloud, array('aws', 'gcp', 'azure'), true) ? $docs_cloud : 'aws';
1022 $docs_region = sanitize_text_field($input['mxchat_pinecone_docs_region'] ?? ($existing['mxchat_pinecone_docs_region'] ?? 'us-east-1'));
1023 $sanitized['mxchat_pinecone_docs_region'] = $docs_region !== '' ? $docs_region : 'us-east-1';
1024 $docs_language = sanitize_key($input['mxchat_pinecone_docs_language'] ?? ($existing['mxchat_pinecone_docs_language'] ?? 'en'));
1025 $sanitized['mxchat_pinecone_docs_language'] = (class_exists('MxChat_Pinecone_Documents') && array_key_exists($docs_language, MxChat_Pinecone_Documents::languages())) ? $docs_language : 'en';
1026 // The namespace has no form field; carry the stored value so a form save cannot drop it.
1027 if (isset($existing['mxchat_pinecone_namespace'])) {
1028 $sanitized['mxchat_pinecone_namespace'] = sanitize_text_field($existing['mxchat_pinecone_namespace']);
1029 }
1030
1031 return $sanitized;
1032 }
1033
1034 public function register_openai_vectorstore_settings() {
1035 register_setting(
1036 'mxchat_openai_vectorstore_options',
1037 'mxchat_openai_vectorstore_options',
1038 array(
1039 'type' => 'array',
1040 'sanitize_callback' => array($this, 'sanitize_openai_vectorstore_settings'),
1041 'default' => array(
1042 'mxchat_use_openai_vectorstore' => '0',
1043 'mxchat_vectorstore_ids' => '',
1044 'mxchat_vectorstore_max_results' => 5,
1045 'mxchat_vectorstore_sync_enabled' => '0',
1046 'mxchat_vectorstore_sync_store_id' => ''
1047 )
1048 )
1049 );
1050 }
1051
1052 public function sanitize_openai_vectorstore_settings($input) {
1053 $sanitized = array();
1054
1055 $sanitized['mxchat_use_openai_vectorstore'] = isset($input['mxchat_use_openai_vectorstore']) ? '1' : '0';
1056 $sanitized['mxchat_vectorstore_ids'] = sanitize_text_field($input['mxchat_vectorstore_ids'] ?? '');
1057 $sanitized['mxchat_vectorstore_max_results'] = absint($input['mxchat_vectorstore_max_results'] ?? 5);
1058
1059 // Knowledge-base sync (write path, plan 15b5c6). Store ID may be blank —
1060 // the sync falls back to the first retrieval ID above.
1061 $sanitized['mxchat_vectorstore_sync_enabled'] = isset($input['mxchat_vectorstore_sync_enabled']) ? '1' : '0';
1062 $sanitized['mxchat_vectorstore_sync_store_id'] = sanitize_text_field($input['mxchat_vectorstore_sync_store_id'] ?? '');
1063
1064 // Ensure max results is within reasonable range
1065 if ($sanitized['mxchat_vectorstore_max_results'] < 1) {
1066 $sanitized['mxchat_vectorstore_max_results'] = 1;
1067 }
1068 if ($sanitized['mxchat_vectorstore_max_results'] > 20) {
1069 $sanitized['mxchat_vectorstore_max_results'] = 20;
1070 }
1071
1072 return $sanitized;
1073 }
1074
1075 /**
1076 * AJAX handler to test OpenAI Vector Store connection
1077 */
1078 public function mxchat_test_vectorstore_connection() {
1079 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1080
1081 if (!current_user_can('manage_options')) {
1082 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1083 return;
1084 }
1085
1086 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
1087 $vectorstore_ids = $vectorstore_options['mxchat_vectorstore_ids'] ?? '';
1088 $mxchat_options = get_option('mxchat_options', array());
1089 $api_key = $mxchat_options['api_key'] ?? '';
1090
1091 if (empty($api_key)) {
1092 wp_send_json_error(array('message' => __('OpenAI API key is not configured.', 'mxchat')));
1093 return;
1094 }
1095
1096 if (empty($vectorstore_ids)) {
1097 wp_send_json_error(array('message' => __('No Vector Store ID configured.', 'mxchat')));
1098 return;
1099 }
1100
1101 // Get the first Vector Store ID for testing
1102 $ids_array = array_map('trim', explode(',', $vectorstore_ids));
1103 $test_id = $ids_array[0];
1104
1105 // Test by retrieving the Vector Store info
1106 $response = wp_remote_get(
1107 'https://api.openai.com/v1/vector_stores/' . $test_id,
1108 array(
1109 'headers' => array(
1110 'Authorization' => 'Bearer ' . $api_key,
1111 'Content-Type' => 'application/json',
1112 'OpenAI-Beta' => 'assistants=v2'
1113 ),
1114 'timeout' => 30
1115 )
1116 );
1117
1118 if (is_wp_error($response)) {
1119 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $response->get_error_message()));
1120 return;
1121 }
1122
1123 $status_code = wp_remote_retrieve_response_code($response);
1124 $body = json_decode(wp_remote_retrieve_body($response), true);
1125
1126 if ($status_code === 200 && isset($body['id'])) {
1127 $file_count = $body['file_counts']['completed'] ?? 0;
1128 $name = $body['name'] ?? $test_id;
1129 wp_send_json_success(array(
1130 'message' => sprintf(
1131 __('Connected successfully! Vector Store: %s (%d files)', 'mxchat'),
1132 esc_html($name),
1133 $file_count
1134 )
1135 ));
1136 } elseif ($status_code === 404) {
1137 wp_send_json_error(array('message' => __('Vector Store not found. Please check the ID.', 'mxchat')));
1138 } elseif ($status_code === 401) {
1139 wp_send_json_error(array('message' => __('Invalid API key.', 'mxchat')));
1140 } else {
1141 $error_message = $body['error']['message'] ?? __('Unknown error occurred.', 'mxchat');
1142 wp_send_json_error(array('message' => $error_message));
1143 }
1144 }
1145
1146 /**
1147 * AJAX handler to test Slack connection and validate scopes
1148 */
1149 public function mxchat_test_slack_connection() {
1150 check_ajax_referer('mxchat_admin_nonce', 'nonce');
1151
1152 if (!current_user_can('manage_options')) {
1153 wp_send_json_error(array('message' => __('Permission denied.', 'mxchat')));
1154 return;
1155 }
1156
1157 $bot_token = $this->options['live_agent_bot_token'] ?? '';
1158
1159 if (empty($bot_token)) {
1160 wp_send_json_error(array('message' => __('Slack Bot Token is not configured. Please enter your token and save settings first.', 'mxchat')));
1161 return;
1162 }
1163
1164 // Test 1: Validate bot token with auth.test
1165 $auth_response = wp_remote_post('https://slack.com/api/auth.test', array(
1166 'headers' => array(
1167 'Authorization' => 'Bearer ' . $bot_token,
1168 'Content-Type' => 'application/json'
1169 ),
1170 'timeout' => 15
1171 ));
1172
1173 if (is_wp_error($auth_response)) {
1174 wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $auth_response->get_error_message()));
1175 return;
1176 }
1177
1178 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
1179
1180 if (!isset($auth_body['ok']) || !$auth_body['ok']) {
1181 $error = $auth_body['error'] ?? 'unknown_error';
1182 $error_messages = array(
1183 'invalid_auth' => __('Invalid bot token. Please check your token starts with xoxb-', 'mxchat'),
1184 'not_authed' => __('No authentication token provided.', 'mxchat'),
1185 'account_inactive' => __('The Slack workspace has been deactivated.', 'mxchat'),
1186 'token_revoked' => __('The bot token has been revoked. Please generate a new one.', 'mxchat'),
1187 );
1188 $message = $error_messages[$error] ?? sprintf(__('Authentication failed: %s', 'mxchat'), $error);
1189 wp_send_json_error(array('message' => $message));
1190 return;
1191 }
1192
1193 $team_name = $auth_body['team'] ?? 'Unknown Workspace';
1194 $bot_name = $auth_body['user'] ?? 'Unknown Bot';
1195
1196 // Test 2: Check if we can list channels (tests channels:read scope)
1197 $channels_response = wp_remote_post('https://slack.com/api/conversations.list', array(
1198 'headers' => array(
1199 'Authorization' => 'Bearer ' . $bot_token,
1200 'Content-Type' => 'application/json'
1201 ),
1202 'body' => json_encode(array('limit' => 1)),
1203 'timeout' => 15
1204 ));
1205
1206 $channels_body = json_decode(wp_remote_retrieve_body($channels_response), true);
1207 $can_read_channels = isset($channels_body['ok']) && $channels_body['ok'];
1208
1209 // Test 3: Check if we can create channels (tests channels:manage scope)
1210 // We'll just check the error message without actually creating
1211 $create_response = wp_remote_post('https://slack.com/api/conversations.create', array(
1212 'headers' => array(
1213 'Authorization' => 'Bearer ' . $bot_token,
1214 'Content-Type' => 'application/json'
1215 ),
1216 'body' => json_encode(array('name' => 'mxchat-test-' . time(), 'is_private' => false)),
1217 'timeout' => 15
1218 ));
1219
1220 $create_body = json_decode(wp_remote_retrieve_body($create_response), true);
1221
1222 // If channel was created, delete it immediately
1223 if (isset($create_body['ok']) && $create_body['ok'] && isset($create_body['channel']['id'])) {
1224 wp_remote_post('https://slack.com/api/conversations.archive', array(
1225 'headers' => array(
1226 'Authorization' => 'Bearer ' . $bot_token,
1227 'Content-Type' => 'application/json'
1228 ),
1229 'body' => json_encode(array('channel' => $create_body['channel']['id'])),
1230 'timeout' => 15
1231 ));
1232 $can_create_channels = true;
1233 } else {
1234 $create_error = $create_body['error'] ?? '';
1235 // name_taken means we have permission but channel exists
1236 $can_create_channels = ($create_error === 'name_taken' || (isset($create_body['ok']) && $create_body['ok']));
1237
1238 // Check for missing scope errors
1239 if ($create_error === 'missing_scope') {
1240 $can_create_channels = false;
1241 }
1242 }
1243
1244 // Build result message
1245 $results = array();
1246 $results[] = sprintf(__('Workspace: %s', 'mxchat'), esc_html($team_name));
1247 $results[] = sprintf(__('Bot: %s', 'mxchat'), esc_html($bot_name));
1248 $results[] = '';
1249 $results[] = ($can_read_channels ? '✓' : '✗') . ' ' . __('channels:read - List channels', 'mxchat');
1250 $results[] = ($can_create_channels ? '✓' : '✗') . ' ' . __('channels:manage - Create channels', 'mxchat');
1251
1252 $missing_scopes = array();
1253 if (!$can_read_channels) $missing_scopes[] = 'channels:read';
1254 if (!$can_create_channels) $missing_scopes[] = 'channels:manage';
1255
1256 if (!empty($missing_scopes)) {
1257 wp_send_json_error(array(
1258 'message' => implode("\n", $results),
1259 'missing_scopes' => $missing_scopes,
1260 'partial' => true
1261 ));
1262 } else {
1263 wp_send_json_success(array(
1264 'message' => implode("\n", $results)
1265 ));
1266 }
1267 }
1268
1269 public function mxchat_display_admin_notice() {
1270 // Success notice
1271 if ($message = get_transient('mxchat_admin_notice_success')) {
1272 ?>
1273 <div class="notice notice-success is-dismissible">
1274 <p><?php echo esc_html($message); ?></p>
1275 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1276 </div>
1277 <?php
1278 delete_transient('mxchat_admin_notice_success'); // Clear the transient after displaying
1279 }
1280
1281 // Error notice
1282 if ($message = get_transient('mxchat_admin_notice_error')) {
1283 ?>
1284 <div class="notice notice-error is-dismissible">
1285 <p><?php echo esc_html($message); ?></p>
1286 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php echo esc_html__('Dismiss this notice.', 'mxchat'); ?></span></button>
1287 </div>
1288 <?php
1289 delete_transient('mxchat_admin_notice_error'); // Clear the transient after displaying
1290 }
1291 }
1292
1293 public function show_live_agent_disabled_banner() {
1294 $show_disabled_notice = get_option('mxchat_show_live_agent_disabled_notice', false);
1295
1296 if ($show_disabled_notice) {
1297 ?>
1298 <div class="mxchat-live-agent-disabled-notice" id="mxchat-disabled-notice">
1299 <div class="mxchat-pro-notification">
1300 <button type="button" class="mxchat-dismiss-btn" onclick="dismissLiveAgentNotice()" aria-label="Dismiss notification">
1301 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1302 <line x1="18" y1="6" x2="6" y2="18"></line>
1303 <line x1="6" y1="6" x2="18" y2="18"></line>
1304 </svg>
1305 </button>
1306 <div class="mxchat-live-agent-content">
1307 <h3>🔧 Live Agent Integration Updated!</h3>
1308 <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>
1309 </div>
1310 </div>
1311 </div>
1312 <?php
1313 }
1314 }
1315 /**
1316 * Render the Onboarding page. Delegates to the procedural renderer in
1317 * includes/admin-onboarding-page.php. Wired to both `?page=mxchat-max`
1318 * (legacy top-level URL) and `?page=mxchat-onboarding` (the canonical
1319 * Onboarding submenu).
1320 *
1321 * When the user has dismissed onboarding and lands on `mxchat-max` (the
1322 * legacy URL, since the Onboarding submenu has been removed), redirect to
1323 * Settings instead — the page is "graduated" and we shouldn't dump them
1324 * back onto it. They can still navigate here directly via the unhide link.
1325 */
1326 public function mxchat_create_dashboard_page() {
1327 require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php';
1328
1329 $current = isset($_GET['page']) ? sanitize_key($_GET['page']) : '';
1330 if ($current === 'mxchat-max' && function_exists('mxchat_onboarding_is_dismissed') && mxchat_onboarding_is_dismissed()) {
1331 wp_safe_redirect(admin_url('admin.php?page=mxchat-settings'));
1332 exit;
1333 }
1334
1335 if (function_exists('mxchat_render_onboarding_page')) {
1336 mxchat_render_onboarding_page();
1337 return;
1338 }
1339 // Defensive: if the include failed to load, fall back to the old Settings page
1340 // so the top-level menu never lands on an empty screen.
1341 $this->mxchat_create_admin_page();
1342 }
1343
1344 /**
1345 * Hide the Onboarding submenu when the user has dismissed it (either
1346 * manually or via auto-graduation). The page itself remains routable so
1347 * the Settings "Show MxChat Onboarding again" link can navigate back to it.
1348 */
1349 public function mxchat_apply_onboarding_visibility() {
1350 if (!function_exists('mxchat_onboarding_is_dismissed')) {
1351 return;
1352 }
1353 if (mxchat_onboarding_is_dismissed()) {
1354 // The first MxChat child is the same-slug-as-parent registration
1355 // (slug 'mxchat-max', labelled "Onboarding") added in plan-d14e89.
1356 // Remove it so the menu opens straight to Settings after dismiss.
1357 remove_submenu_page('mxchat-max', 'mxchat-max');
1358 }
1359 }
1360
1361 public function mxchat_create_admin_page() {
1362 $this->add_live_agent_nonce();
1363 $this->add_theme_migration_nonce();
1364
1365 // Include and render the new sidebar-based settings page
1366 require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php';
1367 mxchat_render_settings_page($this);
1368 }
1369
1370 public function dismiss_live_agent_notice() {
1371 // Add debugging
1372 //error_log('dismiss_live_agent_notice called');
1373 //error_log('POST data: ' . print_r($_POST, true));
1374
1375 // Verify nonce
1376 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_live_agent_notice')) {
1377 //error_log('Nonce verification failed');
1378 wp_die('Security check failed');
1379 }
1380
1381 // Remove the notice flag
1382 $deleted = delete_option('mxchat_show_live_agent_disabled_notice');
1383 //error_log('Option deleted: ' . ($deleted ? 'yes' : 'no'));
1384
1385 wp_send_json_success();
1386 }
1387 public function add_live_agent_nonce() {
1388 if (get_option('mxchat_show_live_agent_disabled_notice', false)) {
1389 // Make sure your admin script is enqueued and localize the data
1390 wp_localize_script('mxchat-admin-js', 'mxchatLiveAgent', array(
1391 'nonce' => wp_create_nonce('dismiss_live_agent_notice'),
1392 'ajaxurl' => admin_url('admin-ajax.php')
1393 ));
1394 }
1395 }
1396
1397 /**
1398 * Show theme migration notice for Pro users with AI-generated themes
1399 * Only shown once - dismissible and stored in options
1400 */
1401 public function show_theme_migration_banner() {
1402 // Only show if Pro is activated
1403 if (!$this->is_activated) {
1404 return;
1405 }
1406
1407 // Check if notice should be shown
1408 $show_notice = get_option('mxchat_show_theme_migration_notice', false);
1409
1410 if ($show_notice) {
1411 ?>
1412 <div class="mxchat-theme-migration-notice" id="mxchat-theme-migration-notice">
1413 <div class="mxchat-pro-notification">
1414 <button type="button" class="mxchat-dismiss-btn" onclick="dismissThemeMigrationNotice()" aria-label="Dismiss notification">
1415 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1416 <line x1="18" y1="6" x2="6" y2="18"></line>
1417 <line x1="6" y1="6" x2="18" y2="18"></line>
1418 </svg>
1419 </button>
1420 <div class="mxchat-theme-migration-content">
1421 <h3>🎨 AI Theme Migration Required</h3>
1422 <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>
1423 </div>
1424 </div>
1425 </div>
1426 <?php
1427 }
1428 }
1429
1430 /**
1431 * Dismiss theme migration notice via AJAX
1432 */
1433 public function dismiss_theme_migration_notice() {
1434 // Verify nonce
1435 if (!wp_verify_nonce($_POST['nonce'], 'dismiss_theme_migration_notice')) {
1436 wp_die('Security check failed');
1437 }
1438
1439 // Remove the notice flag
1440 delete_option('mxchat_show_theme_migration_notice');
1441
1442 wp_send_json_success();
1443 }
1444
1445 /**
1446 * Add nonce for theme migration notice dismiss
1447 */
1448 public function add_theme_migration_nonce() {
1449 if (get_option('mxchat_show_theme_migration_notice', false) && $this->is_activated) {
1450 wp_localize_script('mxchat-admin-js', 'mxchatThemeMigration', array(
1451 'nonce' => wp_create_nonce('dismiss_theme_migration_notice'),
1452 'ajaxurl' => admin_url('admin-ajax.php')
1453 ));
1454 }
1455 }
1456
1457 public function mxchat_create_transcripts_page() {
1458 global $wpdb;
1459 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1460
1461 // Get basic stats
1462 $total_chats = $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM $table_name") ?: 0;
1463 $total_messages = $wpdb->get_var("SELECT COUNT(*) FROM $table_name") ?: 0;
1464
1465 // Count unique users with detailed breakdown
1466 $total_users = $wpdb->get_var("
1467 SELECT COUNT(DISTINCT
1468 CASE
1469 WHEN user_email != '' AND user_email IS NOT NULL THEN user_email
1470 WHEN user_id != 0 THEN CONCAT('user_', user_id)
1471 WHEN user_identifier NOT LIKE 'Tech-Savvy User'
1472 AND user_identifier NOT LIKE 'Detail-Oriented User'
1473 AND user_identifier NOT LIKE 'Language Learner'
1474 AND user_identifier NOT LIKE 'Casual Browser'
1475 AND user_identifier NOT LIKE 'Policy Enforcer'
1476 AND user_identifier NOT LIKE 'Researcher'
1477 AND user_identifier NOT LIKE 'Loyalty Member'
1478 AND user_identifier NOT LIKE 'Gift Buyer'
1479 AND user_identifier NOT LIKE 'Parent or Caregiver'
1480 THEN user_identifier
1481 ELSE session_id
1482 END
1483 )
1484 FROM $table_name
1485 WHERE role != 'assistant'
1486 ");
1487
1488 // Get user type breakdown
1489 $registered_users = $wpdb->get_var("
1490 SELECT COUNT(DISTINCT user_email)
1491 FROM $table_name
1492 WHERE user_email != '' AND user_email IS NOT NULL
1493 ");
1494
1495 $guest_users = $wpdb->get_var("
1496 SELECT COUNT(DISTINCT user_identifier)
1497 FROM $table_name
1498 WHERE (user_email = '' OR user_email IS NULL)
1499 AND role != 'assistant'
1500 AND user_identifier NOT LIKE 'Tech-Savvy User'
1501 AND user_identifier NOT LIKE 'Detail-Oriented User'
1502 AND user_identifier NOT LIKE 'Language Learner'
1503 AND user_identifier NOT LIKE 'Casual Browser'
1504 AND user_identifier NOT LIKE 'Policy Enforcer'
1505 AND user_identifier NOT LIKE 'Researcher'
1506 AND user_identifier NOT LIKE 'Loyalty Member'
1507 AND user_identifier NOT LIKE 'Gift Buyer'
1508 AND user_identifier NOT LIKE 'Parent or Caregiver'
1509 ");
1510
1511 // Get agent test messages count
1512 $agent_tests = $wpdb->get_var("
1513 SELECT COUNT(DISTINCT session_id)
1514 FROM $table_name
1515 WHERE user_identifier IN (
1516 'Tech-Savvy User',
1517 'Detail-Oriented User',
1518 'Language Learner',
1519 'Casual Browser',
1520 'Policy Enforcer',
1521 'Researcher',
1522 'Loyalty Member',
1523 'Gift Buyer',
1524 'Parent or Caregiver'
1525 )
1526 ");
1527 // Get activity metrics
1528 $today_chats = $wpdb->get_var("
1529 SELECT COUNT(DISTINCT session_id)
1530 FROM $table_name
1531 WHERE DATE(timestamp) = CURDATE()
1532 ");
1533
1534 $week_chats = $wpdb->get_var("
1535 SELECT COUNT(DISTINCT session_id)
1536 FROM $table_name
1537 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1538 ");
1539
1540 $month_chats = $wpdb->get_var("
1541 SELECT COUNT(DISTINCT session_id)
1542 FROM $table_name
1543 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY)
1544 ");
1545
1546 // Get daily chat data for last 7 days
1547 $daily_stats = $wpdb->get_results("
1548 SELECT
1549 DATE(timestamp) as date,
1550 COUNT(DISTINCT session_id) as chats,
1551 COUNT(*) as messages
1552 FROM $table_name
1553 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1554 GROUP BY DATE(timestamp)
1555 ORDER BY date ASC
1556 ");
1557
1558 // Get average messages per chat
1559 $avg_messages = $wpdb->get_var("
1560 SELECT AVG(message_count)
1561 FROM (
1562 SELECT session_id, COUNT(*) as message_count
1563 FROM $table_name
1564 GROUP BY session_id
1565 ) as chat_counts
1566 ");
1567 $avg_messages = $avg_messages ? round($avg_messages, 1) : 0;
1568
1569 // Get busiest hour
1570 $busiest_hour = $wpdb->get_row("
1571 SELECT HOUR(timestamp) as hour, COUNT(DISTINCT session_id) as chat_count
1572 FROM $table_name
1573 GROUP BY HOUR(timestamp)
1574 ORDER BY chat_count DESC
1575 LIMIT 1
1576 ");
1577
1578 // Prepare chart data
1579 $chart_labels = array();
1580 $chart_chats = array();
1581 $chart_messages = array();
1582
1583 // Fill last 7 days with data
1584 for ($i = 6; $i >= 0; $i--) {
1585 $date = date('Y-m-d', strtotime("-$i days"));
1586 $day_name = date('D', strtotime("-$i days"));
1587 $chart_labels[] = $day_name;
1588
1589 $found = false;
1590 foreach ($daily_stats as $stat) {
1591 if ($stat->date === $date) {
1592 $chart_chats[] = (int)$stat->chats;
1593 $chart_messages[] = (int)$stat->messages;
1594 $found = true;
1595 break;
1596 }
1597 }
1598 if (!$found) {
1599 $chart_chats[] = 0;
1600 $chart_messages[] = 0;
1601 }
1602 }
1603
1604 // Satisfaction rating rollup — last 30 days, grouped by bot (plan-a5b006).
1605 $satisfaction_stats = $this->get_satisfaction_rating_stats(30);
1606
1607 // Prepare page data for the template
1608 $page_data = array(
1609 'total_chats' => $total_chats,
1610 'total_messages' => $total_messages,
1611 'total_users' => $total_users,
1612 'registered_users' => $registered_users,
1613 'guest_users' => $guest_users,
1614 'agent_tests' => $agent_tests,
1615 'today_chats' => $today_chats,
1616 'week_chats' => $week_chats,
1617 'month_chats' => $month_chats,
1618 'avg_messages' => $avg_messages,
1619 'busiest_hour' => $busiest_hour,
1620 'chart_labels' => $chart_labels,
1621 'chart_chats' => $chart_chats,
1622 'chart_messages' => $chart_messages,
1623 'satisfaction_stats' => $satisfaction_stats,
1624 );
1625
1626 // Include and render the new template
1627 require_once plugin_dir_path(__FILE__) . 'admin-transcripts-page.php';
1628 mxchat_render_transcripts_page($this, $page_data);
1629 }
1630
1631 /**
1632 * Per-bot satisfaction rating rollup over the last $days days. Used by the
1633 * Satisfaction card on the Transcripts dashboard (plan-a5b006).
1634 *
1635 * @param int $days Window in days.
1636 * @return array Each entry: ['bot_id', 'total', 'positive', 'negative', 'positive_pct', 'negative_pct'].
1637 */
1638 public function get_satisfaction_rating_stats($days = 30) {
1639 global $wpdb;
1640 $table = $wpdb->prefix . 'mxchat_session_ratings';
1641 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) {
1642 return array();
1643 }
1644 $days = max(1, (int) $days);
1645 $rows = $wpdb->get_results($wpdb->prepare(
1646 "SELECT bot_id,
1647 COUNT(*) AS total,
1648 SUM(CASE WHEN rating_value = 1 THEN 1 ELSE 0 END) AS positive,
1649 SUM(CASE WHEN rating_value = -1 THEN 1 ELSE 0 END) AS negative
1650 FROM {$table}
1651 WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
1652 GROUP BY bot_id
1653 ORDER BY total DESC",
1654 $days
1655 ));
1656 $out = array();
1657 foreach ((array) $rows as $row) {
1658 $total = (int) $row->total;
1659 $positive = (int) $row->positive;
1660 $negative = (int) $row->negative;
1661 $out[] = array(
1662 'bot_id' => $row->bot_id ?: 'default',
1663 'total' => $total,
1664 'positive' => $positive,
1665 'negative' => $negative,
1666 'positive_pct' => $total > 0 ? (int) round(($positive / $total) * 100) : 0,
1667 'negative_pct' => $total > 0 ? (int) round(($negative / $total) * 100) : 0,
1668 );
1669 }
1670 return $out;
1671 }
1672
1673 /**
1674 * Get chart data for transcripts page
1675 * Used by both page render and script localization
1676 *
1677 * @return array Chart data with labels, chats, and messages arrays
1678 */
1679 private function get_transcripts_chart_data() {
1680 global $wpdb;
1681 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1682
1683 // Get daily chat data for last 7 days - same query as mxchat_transcripts_page()
1684 $daily_stats = $wpdb->get_results("
1685 SELECT
1686 DATE(timestamp) as date,
1687 COUNT(DISTINCT session_id) as chats,
1688 COUNT(*) as messages
1689 FROM $table_name
1690 WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY)
1691 GROUP BY DATE(timestamp)
1692 ORDER BY date ASC
1693 ");
1694
1695 // Prepare chart data
1696 $chart_labels = array();
1697 $chart_chats = array();
1698 $chart_messages = array();
1699
1700 // Fill last 7 days with data - same logic as mxchat_transcripts_page()
1701 for ($i = 6; $i >= 0; $i--) {
1702 $date = date('Y-m-d', strtotime("-$i days"));
1703 $day_name = date('D', strtotime("-$i days"));
1704 $chart_labels[] = $day_name;
1705
1706 $found = false;
1707 if ($daily_stats) {
1708 foreach ($daily_stats as $stat) {
1709 if ($stat->date === $date) {
1710 $chart_chats[] = (int)$stat->chats;
1711 $chart_messages[] = (int)$stat->messages;
1712 $found = true;
1713 break;
1714 }
1715 }
1716 }
1717 if (!$found) {
1718 $chart_chats[] = 0;
1719 $chart_messages[] = 0;
1720 }
1721 }
1722
1723 return array(
1724 'labels' => $chart_labels,
1725 'chats' => $chart_chats,
1726 'messages' => $chart_messages
1727 );
1728 }
1729
1730 public function mxchat_transcripts_notification_section_callback() {
1731 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>';
1732 }
1733 public function mxchat_enable_notifications_callback() {
1734 $options = get_option('mxchat_transcripts_options', array());
1735 $enabled = isset($options['mxchat_enable_notifications']) ? $options['mxchat_enable_notifications'] : 0;
1736 ?>
1737 <label for="mxchat_enable_notifications">
1738 <input type="checkbox" id="mxchat_enable_notifications"
1739 name="mxchat_transcripts_options[mxchat_enable_notifications]"
1740 value="1" <?php checked(1, $enabled); ?>>
1741 <?php esc_html_e('Send email notification when a new chat session starts', 'mxchat'); ?>
1742 </label>
1743 <p class="description">
1744 <?php esc_html_e('Enable this option to receive email notifications for new chat sessions.', 'mxchat'); ?>
1745 </p>
1746 <?php
1747 }
1748 public function mxchat_notification_email_callback() {
1749 $options = get_option('mxchat_transcripts_options', array());
1750 $email = isset($options['mxchat_notification_email']) ? $options['mxchat_notification_email'] : get_option('admin_email');
1751 ?>
1752 <?php /* type="text", not "email": the browser rejects a comma-separated list outright (plan 2f131a). Validation is server-side in MxChat_Utils. */ ?>
1753 <input type="text" id="mxchat_notification_email"
1754 name="mxchat_transcripts_options[mxchat_notification_email]"
1755 value="<?php echo esc_attr($email); ?>"
1756 class="regular-text">
1757 <p class="description">
1758 <?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); ?>
1759 </p>
1760 <?php
1761 }
1762
1763 public function mxchat_auto_delete_transcripts_callback() {
1764 $options = get_option('mxchat_transcripts_options', array());
1765 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1766 ?>
1767 <select id="mxchat_auto_delete_transcripts"
1768 name="mxchat_transcripts_options[mxchat_auto_delete_transcripts]">
1769 <option value="never" <?php selected($interval, 'never'); ?>>
1770 <?php esc_html_e('Never (Keep All Transcripts)', 'mxchat'); ?>
1771 </option>
1772 <option value="1week" <?php selected($interval, '1week'); ?>>
1773 <?php esc_html_e('After 1 Week', 'mxchat'); ?>
1774 </option>
1775 <option value="2weeks" <?php selected($interval, '2weeks'); ?>>
1776 <?php esc_html_e('After 2 Weeks', 'mxchat'); ?>
1777 </option>
1778 <option value="1month" <?php selected($interval, '1month'); ?>>
1779 <?php esc_html_e('After 1 Month', 'mxchat'); ?>
1780 </option>
1781 </select>
1782 <p class="description">
1783 <?php esc_html_e('Automatically delete old chat transcripts after the selected time period. This helps manage database size and privacy.', 'mxchat'); ?>
1784 </p>
1785 <?php
1786 }
1787
1788 /**
1789 * Custom retention-days input. When > 0 it overrides the bucket dropdown above
1790 * and deletes transcripts older than the given number of days. Set to 0 to fall
1791 * back to the dropdown (or "Never" if the dropdown is also Never).
1792 *
1793 * Devs can override the final day count via the `mxchat_transcript_retention_days`
1794 * filter — runs in `cleanup_old_transcripts()` after this option is read.
1795 *
1796 * (plan-mxchat-20260509-9b80b1)
1797 */
1798 public function mxchat_retention_days_callback() {
1799 $options = get_option('mxchat_transcripts_options', array());
1800 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1801 ?>
1802 <input type="number"
1803 id="mxchat_retention_days"
1804 name="mxchat_transcripts_options[mxchat_retention_days]"
1805 value="<?php echo esc_attr($days); ?>"
1806 min="0"
1807 max="3650"
1808 step="1"
1809 style="width: 90px;" />
1810 <p class="description">
1811 <?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'); ?>
1812 <br>
1813 <code>apply_filters( 'mxchat_transcript_retention_days', $days )</code>
1814 <?php esc_html_e('lets developers override the final day count programmatically.', 'mxchat'); ?>
1815 </p>
1816 <?php
1817 }
1818
1819 public function mxchat_auto_email_transcript_callback() {
1820 $options = get_option('mxchat_transcripts_options', array());
1821 $enabled = isset($options['mxchat_auto_email_transcript_enabled']) ? $options['mxchat_auto_email_transcript_enabled'] : 0;
1822 $delay = isset($options['mxchat_auto_email_transcript_delay']) ? $options['mxchat_auto_email_transcript_delay'] : '30';
1823 $require_contact = isset($options['mxchat_auto_email_transcript_require_contact']) ? $options['mxchat_auto_email_transcript_require_contact'] : 0;
1824 ?>
1825 <label>
1826 <input type="checkbox"
1827 id="mxchat_auto_email_transcript_enabled"
1828 name="mxchat_transcripts_options[mxchat_auto_email_transcript_enabled]"
1829 value="1"
1830 <?php checked($enabled, 1); ?>>
1831 <?php esc_html_e('Enable Auto-Email of Full Transcript', 'mxchat'); ?>
1832 </label>
1833 <br><br>
1834 <label for="mxchat_auto_email_transcript_delay">
1835 <?php esc_html_e('Send transcript after:', 'mxchat'); ?>
1836 </label>
1837 <select id="mxchat_auto_email_transcript_delay"
1838 name="mxchat_transcripts_options[mxchat_auto_email_transcript_delay]">
1839 <option value="15" <?php selected($delay, '15'); ?>>
1840 <?php esc_html_e('15 minutes', 'mxchat'); ?>
1841 </option>
1842 <option value="30" <?php selected($delay, '30'); ?>>
1843 <?php esc_html_e('30 minutes', 'mxchat'); ?>
1844 </option>
1845 <option value="60" <?php selected($delay, '60'); ?>>
1846 <?php esc_html_e('1 hour', 'mxchat'); ?>
1847 </option>
1848 </select>
1849 <br><br>
1850 <label>
1851 <input type="checkbox"
1852 id="mxchat_auto_email_transcript_require_contact"
1853 name="mxchat_transcripts_options[mxchat_auto_email_transcript_require_contact]"
1854 value="1"
1855 <?php checked($require_contact, 1); ?>>
1856 <?php esc_html_e('Only send if visitor provided contact info', 'mxchat'); ?>
1857 </label>
1858 <p class="description">
1859 <?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'); ?>
1860 <br>
1861 <?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'); ?>
1862 </p>
1863 <?php
1864 }
1865
1866
1867
1868 public function sanitize_transcripts_options($input) {
1869 $sanitized = array();
1870
1871 $sanitized['mxchat_enable_notifications'] = isset($input['mxchat_enable_notifications']) ? 1 : 0;
1872
1873 if (isset($input['mxchat_notification_email'])) {
1874 // Same list rule as the Transcripts autosave path (plan 2f131a). These two
1875 // paths disagreeing is how the original bug survived: this one DID call
1876 // is_email(), but on sanitize_email()'s output — which is valid even when
1877 // the input was two addresses mashed into one. See MxChat_Utils.
1878 $parsed = MxChat_Utils::parse_notification_emails($input['mxchat_notification_email']);
1879 if ($parsed['error'] !== '') {
1880 add_settings_error(
1881 'mxchat_transcripts_options',
1882 'invalid_email',
1883 $parsed['error'],
1884 'error'
1885 );
1886 // Keep whatever was already stored. Do NOT fall back to admin_email:
1887 // that fallback belongs to a genuinely EMPTY field, and using it here
1888 // would quietly redirect notifications away from the address on screen.
1889 $existing = get_option('mxchat_transcripts_options', array());
1890 if (is_array($existing) && isset($existing['mxchat_notification_email'])) {
1891 $sanitized['mxchat_notification_email'] = $existing['mxchat_notification_email'];
1892 }
1893 } else {
1894 $sanitized['mxchat_notification_email'] = implode(', ', $parsed['emails']);
1895 }
1896 }
1897
1898 // Sanitize auto-delete setting
1899 $valid_intervals = array('never', '1week', '2weeks', '1month');
1900 if (isset($input['mxchat_auto_delete_transcripts'])) {
1901 $sanitized['mxchat_auto_delete_transcripts'] = in_array($input['mxchat_auto_delete_transcripts'], $valid_intervals)
1902 ? $input['mxchat_auto_delete_transcripts']
1903 : 'never';
1904 } else {
1905 $sanitized['mxchat_auto_delete_transcripts'] = 'never';
1906 }
1907
1908 // Sanitize custom retention-days override (plan-9b80b1).
1909 if (isset($input['mxchat_retention_days'])) {
1910 $days = (int) $input['mxchat_retention_days'];
1911 $sanitized['mxchat_retention_days'] = max(0, min(3650, $days));
1912 } else {
1913 // Absent key = a partial save (WP-CLI patch, a snippet, any
1914 // update_option with a subset — this filter runs on ALL of them).
1915 // Preserve the stored value instead of zeroing it: resetting here
1916 // silently discarded a hand-set retention on the next unrelated
1917 // save of this option (plan-3c3338). The page's own autosave path
1918 // bypasses this filter entirely (direct $wpdb write), so this
1919 // branch only ever sees programmatic saves.
1920 $mxch_prev = get_option('mxchat_transcripts_options', array());
1921 $sanitized['mxchat_retention_days'] = (is_array($mxch_prev) && isset($mxch_prev['mxchat_retention_days']))
1922 ? max(0, min(3650, (int) $mxch_prev['mxchat_retention_days']))
1923 : 0;
1924 }
1925
1926 // Get old values to check if auto-delete or retention-days changed.
1927 $old_options = get_option('mxchat_transcripts_options');
1928 $old_interval = isset($old_options['mxchat_auto_delete_transcripts']) ? $old_options['mxchat_auto_delete_transcripts'] : 'never';
1929 $old_retention = isset($old_options['mxchat_retention_days']) ? (int) $old_options['mxchat_retention_days'] : 0;
1930
1931 // If either setting changed, reschedule the cron job. The schedule_transcript_cleanup
1932 // helper now treats "any active retention" (dropdown != never OR custom days > 0) as a
1933 // reason to keep the daily cron registered.
1934 $interval_changed = ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']);
1935 $retention_changed = ($old_retention !== $sanitized['mxchat_retention_days']);
1936 if ($interval_changed || $retention_changed) {
1937 $any_active = ($sanitized['mxchat_auto_delete_transcripts'] !== 'never') || ($sanitized['mxchat_retention_days'] > 0);
1938 $this->schedule_transcript_cleanup($any_active ? 'active' : 'never');
1939 }
1940
1941 // Sanitize auto-email transcript settings
1942 $sanitized['mxchat_auto_email_transcript_enabled'] = isset($input['mxchat_auto_email_transcript_enabled']) ? 1 : 0;
1943
1944 $valid_delays = array('15', '30', '60');
1945 if (isset($input['mxchat_auto_email_transcript_delay'])) {
1946 $sanitized['mxchat_auto_email_transcript_delay'] = in_array($input['mxchat_auto_email_transcript_delay'], $valid_delays)
1947 ? $input['mxchat_auto_email_transcript_delay']
1948 : '30';
1949 } else {
1950 $sanitized['mxchat_auto_email_transcript_delay'] = '30';
1951 }
1952
1953 // Sanitize require contact info setting
1954 $sanitized['mxchat_auto_email_transcript_require_contact'] = isset($input['mxchat_auto_email_transcript_require_contact']) ? 1 : 0;
1955
1956 return $sanitized;
1957 }
1958
1959 /**
1960 * Schedule or unschedule the transcript cleanup cron job
1961 */
1962 public function schedule_transcript_cleanup($interval) {
1963 // Clear any existing scheduled event
1964 $timestamp = wp_next_scheduled('mxchat_cleanup_old_transcripts');
1965 if ($timestamp) {
1966 wp_unschedule_event($timestamp, 'mxchat_cleanup_old_transcripts');
1967 }
1968
1969 // Schedule new event whenever retention is active. "active" is the canonical
1970 // value passed by sanitize_transcripts_options when either the dropdown != never
1971 // OR the custom retention-days > 0; "never" turns the cron off. Any other value
1972 // (the legacy "1week" / "2weeks" / "1month" strings) is also treated as active.
1973 if ($interval !== 'never') {
1974 // Schedule to run daily at 3 AM
1975 $next_run = strtotime('tomorrow 3:00 AM');
1976 wp_schedule_event($next_run, 'daily', 'mxchat_cleanup_old_transcripts');
1977 }
1978 }
1979
1980 /**
1981 * Self-heal guard: keep the cleanup cron in sync with the retention setting.
1982 *
1983 * Runs on admin_init. Cost when nothing is wrong: one get_option() (cached) and
1984 * one wp_next_scheduled() (reads the cached cron option) — no writes. It only
1985 * schedules when retention is active but the event is missing, and only
1986 * unschedules when retention is off but the event survived. Both directions
1987 * reuse schedule_transcript_cleanup() so there is exactly one scheduling path.
1988 * Legacy dropdown values (1week/2weeks/1month) count as active, matching the
1989 * helper's own semantics. Deliberately not gated on DISABLE_WP_CRON: scheduling
1990 * writes the cron option regardless of how cron is executed, so a server-side
1991 * cron runner still picks the event up.
1992 */
1993 public function ensure_transcript_cleanup_scheduled() {
1994 $options = get_option('mxchat_transcripts_options', array());
1995 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
1996 $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
1997 $active = ($interval !== 'never') || ($days > 0);
1998 $scheduled = (bool) wp_next_scheduled('mxchat_cleanup_old_transcripts');
1999
2000 if ($active && !$scheduled) {
2001 $this->schedule_transcript_cleanup('active');
2002 } elseif (!$active && $scheduled) {
2003 $this->schedule_transcript_cleanup('never');
2004 }
2005 }
2006
2007 /**
2008 * Delete old transcripts based on the configured interval
2009 */
2010 public function cleanup_old_transcripts() {
2011 $options = get_option('mxchat_transcripts_options', array());
2012 $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never';
2013 $custom_days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0;
2014
2015 // Custom retention-days (plan-9b80b1) takes precedence over the bucket dropdown.
2016 $days = 0;
2017 if ($custom_days > 0) {
2018 $days = $custom_days;
2019 } else {
2020 switch ($interval) {
2021 case '1week': $days = 7; break;
2022 case '2weeks': $days = 14; break;
2023 case '1month': $days = 30; break;
2024 case 'never':
2025 default:
2026 $days = 0;
2027 }
2028 }
2029
2030 // Devs can override the final day count programmatically.
2031 $days = (int) apply_filters('mxchat_transcript_retention_days', $days);
2032
2033 if ($days <= 0) {
2034 return; // Retention disabled — bail.
2035 }
2036
2037 global $wpdb;
2038 $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts';
2039 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2040 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
2041
2042 // Defensive: if the main table doesn't exist (fresh-ish install), bail.
2043 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) {
2044 return;
2045 }
2046
2047 $cutoff_date = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
2048
2049 // Cap at 5000 session_ids per run so a large unattended site doesn't OOM —
2050 // the cron will pick up where it left off on the next tick (within hours).
2051 $batch_cap = (int) apply_filters('mxchat_transcript_retention_batch_cap', 5000);
2052
2053 $sessions_to_delete = $wpdb->get_col(
2054 $wpdb->prepare(
2055 "SELECT DISTINCT session_id FROM {$transcripts_table} WHERE timestamp IS NOT NULL AND timestamp < %s LIMIT %d",
2056 $cutoff_date,
2057 $batch_cap
2058 )
2059 );
2060
2061 if (empty($sessions_to_delete)) {
2062 return;
2063 }
2064
2065 $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s'));
2066
2067 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2068 // $placeholders is a server-built list of literal "%s" tokens.
2069 $deleted_transcripts = (int) $wpdb->query(
2070 $wpdb->prepare(
2071 "DELETE FROM {$transcripts_table} WHERE session_id IN ($placeholders)",
2072 $sessions_to_delete
2073 )
2074 );
2075
2076 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) {
2077 $wpdb->query(
2078 $wpdb->prepare(
2079 "DELETE FROM {$translations_table} WHERE session_id IN ($placeholders)",
2080 $sessions_to_delete
2081 )
2082 );
2083 }
2084
2085 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) {
2086 $wpdb->query(
2087 $wpdb->prepare(
2088 "DELETE FROM {$url_clicks_table} WHERE session_id IN ($placeholders)",
2089 $sessions_to_delete
2090 )
2091 );
2092 }
2093 // phpcs:enable
2094
2095 update_option('mxchat_retention_last_swept_at', time(), false);
2096 update_option('mxchat_retention_rows_last_deleted', $deleted_transcripts, false);
2097 }
2098
2099 public function export_chat_transcripts() {
2100 if (!current_user_can('manage_options')) {
2101 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2102 }
2103
2104 check_ajax_referer('mxchat_export_transcripts', 'security');
2105
2106 global $wpdb;
2107 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
2108
2109 // Get all transcripts ordered by session and timestamp
2110 $results = $wpdb->get_results(
2111 "SELECT session_id, user_email, user_identifier, role, message, timestamp
2112 FROM {$table_name}
2113 ORDER BY session_id, timestamp ASC"
2114 );
2115
2116 if (empty($results)) {
2117 wp_send_json_error(array('message' => 'No transcripts found.'));
2118 wp_die();
2119 }
2120
2121 // Set headers for CSV download
2122 header('Content-Type: text/csv');
2123 header('Content-Disposition: attachment; filename="chat-transcripts-' . date('Y-m-d') . '.csv"');
2124 header('Pragma: no-cache');
2125 header('Expires: 0');
2126
2127 // Create output stream
2128 $output = fopen('php://output', 'w');
2129
2130 // Add UTF-8 BOM for proper Excel encoding
2131 fputs($output, "\xEF\xBB\xBF");
2132
2133 // Add CSV headers
2134 fputcsv($output, array(
2135 'Session ID',
2136 'Email',
2137 'User Identifier',
2138 'Role',
2139 'Message',
2140 'Timestamp'
2141 ));
2142
2143 // Add data rows
2144 foreach ($results as $row) {
2145 fputcsv($output, array(
2146 $row->session_id,
2147 $row->user_email,
2148 $row->user_identifier,
2149 $row->role,
2150 $row->message,
2151 $row->timestamp
2152 ));
2153 }
2154
2155 fclose($output);
2156 wp_die();
2157 }
2158
2159 // ============================================================================
2160 // Leads tab (inside Transcripts)
2161 //
2162 // Leads are derived from existing data — no dedicated table. Primary source:
2163 // wp_mxchat_chat_transcripts rows where user_email is populated. Secondary
2164 // source: the mxchat_sessions table's visitor_email/visitor_name columns
2165 // (plus any unmigrated legacy `mxchat_email_{sid}` wp_options rows) for
2166 // "orphan" leads who submitted the pre-chat form but never chatted.
2167 // ============================================================================
2168
2169 /**
2170 * Fetch leads: dedup-by-email rows, stats strip, and top pages in one call.
2171 */
2172 public function mxchat_fetch_leads() {
2173 if (!current_user_can('manage_options')) {
2174 wp_send_json_error(['message' => 'Insufficient permissions']);
2175 wp_die();
2176 }
2177
2178 $date_range = isset($_POST['date_range']) ? sanitize_key($_POST['date_range']) : 'all';
2179
2180 wp_send_json(self::mxchat_collect_leads([
2181 'page' => isset($_POST['page']) ? max(1, absint($_POST['page'])) : 1,
2182 'per_page' => isset($_POST['per_page']) ? min(100, max(10, absint($_POST['per_page']))) : 25,
2183 'search' => isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '',
2184 'date_cutoff' => self::mxchat_leads_date_cutoff($date_range),
2185 'status' => isset($_POST['status']) ? sanitize_key($_POST['status']) : 'all',
2186 'page_url' => isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '',
2187 'sort' => isset($_POST['sort']) ? sanitize_key($_POST['sort']) : 'last_seen',
2188 'sort_dir' => (isset($_POST['sort_dir']) && $_POST['sort_dir'] === 'asc') ? 'asc' : 'desc',
2189 ]));
2190 wp_die();
2191 }
2192
2193 /**
2194 * Build the Leads payload: the transcripts-derived list plus the chat-deleted
2195 * and orphan buckets, paginated and hydrated exactly as the Leads tab shows it.
2196 *
2197 * Extracted from mxchat_fetch_leads() so the REST route (f958cf) reads leads
2198 * through the same query rather than growing a second one that would drift —
2199 * in particular from the address-less lead key introduced by c0cfaf. Callers
2200 * own their own capability check and their own per_page ceiling; this method
2201 * reads no superglobals and echoes nothing.
2202 *
2203 * @param array $args page, per_page, search, date_cutoff (MySQL datetime or ''),
2204 * status (all|orphan|chat_deleted), page_url, sort, sort_dir.
2205 * @return array
2206 */
2207 public static function mxchat_collect_leads(array $args = []) {
2208 global $wpdb;
2209 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2210
2211 $page = max(1, (int) ($args['page'] ?? 1));
2212 $per_page = max(1, (int) ($args['per_page'] ?? 25));
2213 $offset = ($page - 1) * $per_page;
2214 $search = (string) ($args['search'] ?? '');
2215 $status = (string) ($args['status'] ?? 'all');
2216 $page_filter = (string) ($args['page_url'] ?? '');
2217 $sort = (string) ($args['sort'] ?? 'last_seen');
2218 $sort_dir = (isset($args['sort_dir']) && $args['sort_dir'] === 'asc') ? 'ASC' : 'DESC';
2219
2220 $date_cutoff = (string) ($args['date_cutoff'] ?? '');
2221 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2222
2223 // Base WHERE for transcripts leads.
2224 //
2225 // Email is the row key whenever there IS one. With "Require Email Address"
2226 // turned off (c0cfaf) a capture can be name-only, and keying on email
2227 // would drop it from this list entirely — the lead would be collected and
2228 // then invisible. Those rows key on session id instead, written as
2229 // "session:<id>" so the two kinds of key can never collide.
2230 //
2231 // The widening is gated on the option: while email is required (the
2232 // default, and every install that has not opted in) both the WHERE and the
2233 // GROUP BY reduce to exactly what they were before.
2234 $lead_key_sql = self::mxchat_leads_key_sql();
2235 $email_optional = self::mxchat_leads_email_optional();
2236
2237 $where_clauses = $email_optional
2238 ? ["((user_email IS NOT NULL AND user_email != '') OR (user_name IS NOT NULL AND user_name != ''))"]
2239 : ["user_email IS NOT NULL", "user_email != ''"];
2240 $where_params = [];
2241
2242 if ($date_cutoff) {
2243 $where_clauses[] = 'timestamp >= %s';
2244 $where_params[] = $date_cutoff;
2245 }
2246 if ($page_filter && $has_page_url_column) {
2247 $where_clauses[] = 'originating_page_url = %s';
2248 $where_params[] = $page_filter;
2249 }
2250 if ($search !== '') {
2251 $like = '%' . $wpdb->esc_like($search) . '%';
2252 $where_clauses[] = '(user_email LIKE %s OR user_name LIKE %s)';
2253 $where_params[] = $like;
2254 $where_params[] = $like;
2255 }
2256 $where_sql = 'WHERE ' . implode(' AND ', $where_clauses);
2257
2258 // Aggregate query grouped by the lead key (email, or session id when the
2259 // capture had no email). MAX(user_email) is safe as the display address:
2260 // rows sharing an email key all carry that same address, and rows keyed by
2261 // session have no address to disagree about.
2262 $select_sql = "SELECT {$lead_key_sql} AS lead_key, MAX(user_email) AS user_email,
2263 MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen,
2264 COUNT(DISTINCT session_id) AS conversation_count";
2265
2266 $order_column = in_array($sort, ['last_seen', 'conversation_count', 'first_seen'], true) ? $sort : 'last_seen';
2267 $group_order_limit = " GROUP BY lead_key ORDER BY {$order_column} {$sort_dir} LIMIT %d OFFSET %d";
2268
2269 $transcripts_sql = $wpdb->prepare(
2270 "{$select_sql} FROM {$table} {$where_sql}{$group_order_limit}",
2271 array_merge($where_params, [$per_page, $offset])
2272 );
2273 $transcript_rows = $wpdb->get_results($transcripts_sql);
2274
2275 // Count of unique transcript-based leads under the same filters.
2276 $count_sql = $wpdb->prepare(
2277 "SELECT COUNT(DISTINCT {$lead_key_sql}) FROM {$table} {$where_sql}",
2278 $where_params
2279 );
2280 $transcripts_lead_count = (int) $wpdb->get_var($count_sql);
2281
2282 // Hydrate each row: name, latest_session_id, top page.
2283 $leads = [];
2284 foreach ($transcript_rows as $row) {
2285 $detail = $has_page_url_column
2286 ? $wpdb->get_row($wpdb->prepare(
2287 "SELECT session_id, user_name, originating_page_url, originating_page_title
2288 FROM {$table} WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2289 $row->lead_key
2290 ))
2291 : $wpdb->get_row($wpdb->prepare(
2292 "SELECT session_id, user_name FROM {$table}
2293 WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2294 $row->lead_key
2295 ));
2296
2297 $leads[] = [
2298 'lead_key' => $row->lead_key,
2299 'email' => (string) $row->user_email,
2300 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2301 'conversation_count' => (int) $row->conversation_count,
2302 'last_seen' => $row->last_seen,
2303 'last_seen_display' => self::mxchat_leads_format_relative($row->last_seen),
2304 'first_seen' => $row->first_seen,
2305 'latest_session_id' => isset($detail->session_id) ? $detail->session_id : '',
2306 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2307 'top_page_title' => isset($detail->originating_page_title) ? $detail->originating_page_title : '',
2308 'is_orphan' => false,
2309 'status' => 'active',
2310 ];
2311 }
2312
2313 // Non-transcript lead sources. Built once here, then filtered/merged based on the
2314 // status filter below. Deduplication priority when the same email appears in multiple
2315 // sources: transcripts > chat_deleted > orphan.
2316 $transcripts_emails_seen = array_flip(array_map(
2317 function ($r) { return self::mxchat_leads_dedup_key($r); },
2318 $leads
2319 ));
2320
2321 // Chat-deleted leads: had a conversation that an admin removed. Preserved via
2322 // mxchat_lead_del_* options, with timestamps so they respect date filters.
2323 $chat_deleted_leads_all = [];
2324 if ($status === 'all' || $status === 'chat_deleted') {
2325 $chat_deleted_leads_all = self::mxchat_collect_chat_deleted_leads($search, $date_cutoff);
2326 // Dedup: drop any chat_deleted row whose email is already in the transcripts set.
2327 $chat_deleted_leads_all = array_values(array_filter(
2328 $chat_deleted_leads_all,
2329 function ($row) use ($transcripts_emails_seen) {
2330 return !isset($transcripts_emails_seen[self::mxchat_leads_dedup_key($row)]);
2331 }
2332 ));
2333 foreach ($chat_deleted_leads_all as $row) {
2334 $transcripts_emails_seen[self::mxchat_leads_dedup_key($row)] = true;
2335 }
2336 }
2337
2338 // Orphans: pre-chat form captures with no conversation. No timestamp, so skipped
2339 // when a date filter is active.
2340 $orphan_leads_all = [];
2341 if (($status === 'all' || $status === 'orphan') && !$date_cutoff && !$page_filter) {
2342 $orphan_leads_all = self::mxchat_collect_orphan_leads($search);
2343 $orphan_leads_all = array_values(array_filter(
2344 $orphan_leads_all,
2345 function ($row) use ($transcripts_emails_seen) {
2346 return !isset($transcripts_emails_seen[self::mxchat_leads_dedup_key($row)]);
2347 }
2348 ));
2349 }
2350
2351 // Apply status filter to the transcripts-derived list.
2352 if ($status === 'orphan' || $status === 'chat_deleted') {
2353 $leads = [];
2354 $transcripts_lead_count = 0;
2355 }
2356
2357 // Stitch the current page from the three buckets in priority order.
2358 $total_count = $transcripts_lead_count + count($chat_deleted_leads_all) + count($orphan_leads_all);
2359 $remaining_slots = $per_page - count($leads);
2360
2361 if ($remaining_slots > 0 && !empty($chat_deleted_leads_all)) {
2362 $start = max(0, ($page - 1) * $per_page - $transcripts_lead_count);
2363 if ($start < count($chat_deleted_leads_all)) {
2364 $leads = array_merge($leads, array_slice($chat_deleted_leads_all, $start, $remaining_slots));
2365 $remaining_slots = $per_page - count($leads);
2366 }
2367 }
2368
2369 if ($remaining_slots > 0 && !empty($orphan_leads_all)) {
2370 $before = $transcripts_lead_count + count($chat_deleted_leads_all);
2371 $start = max(0, ($page - 1) * $per_page - $before);
2372 if ($start < count($orphan_leads_all)) {
2373 $leads = array_merge($leads, array_slice($orphan_leads_all, $start, $remaining_slots));
2374 }
2375 }
2376
2377 $total_pages = $per_page > 0 ? (int) ceil($total_count / $per_page) : 1;
2378
2379 // Stats strip: always computed over full dataset, unaffected by filters.
2380 $stats = self::mxchat_leads_stats($table, $has_page_url_column);
2381
2382 // Top pages: top 5 by distinct emails captured.
2383 $top_pages = [];
2384 if ($has_page_url_column) {
2385 $top_pages_rows = $wpdb->get_results(
2386 "SELECT originating_page_url AS url,
2387 MAX(originating_page_title) AS title,
2388 COUNT(DISTINCT user_email) AS lead_count
2389 FROM {$table}
2390 WHERE user_email IS NOT NULL AND user_email != ''
2391 AND originating_page_url IS NOT NULL AND originating_page_url != ''
2392 GROUP BY originating_page_url
2393 ORDER BY lead_count DESC, url ASC
2394 LIMIT 5"
2395 );
2396 foreach ($top_pages_rows as $p) {
2397 $top_pages[] = [
2398 'url' => $p->url,
2399 'title' => $p->title ?: $p->url,
2400 'lead_count' => (int) $p->lead_count,
2401 ];
2402 }
2403 }
2404
2405 // Consent state per lead (b062c4). Chat-deleted rows already carry it from
2406 // their preserved options; the rest read the newest session-store record
2407 // for that email. Empty string = NOT RECORDED (pre-feature capture or the
2408 // checkbox was off) — distinct from 'no', which is a recorded decline.
2409 $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent');
2410 foreach ($leads as &$lead_consent_ref) {
2411 if (array_key_exists('consent', $lead_consent_ref)) {
2412 continue;
2413 }
2414 $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($lead_consent_ref['email']) : null;
2415 $lead_consent_ref['consent'] = $consent ? $consent['given'] : '';
2416 $lead_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : '';
2417 $lead_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : '';
2418 }
2419 unset($lead_consent_ref);
2420
2421 return [
2422 'success' => true,
2423 'leads' => $leads,
2424 'page' => $page,
2425 'per_page' => $per_page,
2426 'total_count' => $total_count,
2427 'total_pages' => $total_pages,
2428 'showing_start' => $total_count === 0 ? 0 : ($offset + 1),
2429 'showing_end' => min($offset + $per_page, $total_count),
2430 'stats' => $stats,
2431 'top_pages' => $top_pages,
2432 ];
2433 }
2434
2435 /**
2436 * Delete one or more leads by email. Removes every transcripts row for that
2437 * email and cleans up related wp_options (mxchat_email_{sid}, mxchat_name_{sid},
2438 * mxchat_history_{sid}) and any orphan option entries matching the email.
2439 */
2440 public function mxchat_delete_leads() {
2441 if (!current_user_can('manage_options')) {
2442 wp_send_json_error(['message' => 'Insufficient permissions']);
2443 wp_die();
2444 }
2445 check_ajax_referer('mxchat_delete_leads', 'security');
2446
2447 // The tab sends back the key it was given, which is an address for most
2448 // leads and "session:<id>" for a name-only capture (c0cfaf). Splitting
2449 // here keeps sanitize_email() as the gate for the email half — an empty
2450 // or malformed value is dropped rather than turned into a WHERE clause
2451 // that would match every address-less row at once.
2452 $keys = self::mxchat_leads_split_keys(
2453 isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : []
2454 );
2455 $emails = $keys['emails'];
2456 $sessions = $keys['sessions'];
2457
2458 if (empty($emails) && empty($sessions)) {
2459 wp_send_json_error(['message' => 'No emails provided']);
2460 wp_die();
2461 }
2462
2463 $summary = ['deleted_sessions' => 0, 'deleted_rows' => 0];
2464 if (!empty($emails)) {
2465 $summary = self::mxchat_wipe_leads_by_email($emails);
2466 }
2467 if (!empty($sessions)) {
2468 $session_summary = self::mxchat_wipe_leads_by_session($sessions);
2469 $summary['deleted_sessions'] += $session_summary['deleted_sessions'];
2470 $summary['deleted_rows'] += $session_summary['deleted_rows'];
2471 }
2472
2473 wp_send_json([
2474 'success' => true,
2475 'deleted_leads' => count($emails) + count($sessions),
2476 'deleted_sessions' => $summary['deleted_sessions'],
2477 'deleted_rows' => $summary['deleted_rows'],
2478 ]);
2479 wp_die();
2480 }
2481
2482 /**
2483 * Wipe address-less leads by session id (c0cfaf) — the session-keyed twin of
2484 * mxchat_wipe_leads_by_email(). Same per-session option cleanup; no
2485 * email-keyed orphan sweep, because these leads have no address to sweep on.
2486 */
2487 private static function mxchat_wipe_leads_by_session(array $session_ids) {
2488 global $wpdb;
2489 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2490 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2491 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2492
2493 $deleted_sessions = 0;
2494 $deleted_rows = 0;
2495
2496 foreach ($session_ids as $sid) {
2497 if ($sid === '') {
2498 continue;
2499 }
2500
2501 $rows_removed = $wpdb->delete($table, ['session_id' => $sid], ['%s']);
2502 if ($rows_removed !== false) {
2503 $deleted_rows += (int) $rows_removed;
2504 }
2505
2506 $deleted_sessions++;
2507 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2508 delete_option('mxchat_history_' . $sid);
2509 delete_option('mxchat_email_' . $sid);
2510 delete_option('mxchat_name_' . $sid);
2511 delete_option('mxchat_agent_name_' . $sid);
2512 delete_option('mxchat_lead_del_email_' . $sid);
2513 delete_option('mxchat_lead_del_name_' . $sid);
2514 delete_option('mxchat_lead_del_ts_' . $sid);
2515 delete_option('mxchat_lead_del_consent_' . $sid);
2516 delete_option('mxchat_lead_del_consent_label_' . $sid);
2517 delete_option('mxchat_lead_del_consent_at_' . $sid);
2518 if (class_exists('MxChat_Session_Store')) {
2519 MxChat_Session_Store::delete_session($sid); // b64b77
2520 }
2521
2522 if ($has_translations) {
2523 $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2524 }
2525 }
2526
2527 return ['deleted_sessions' => $deleted_sessions, 'deleted_rows' => $deleted_rows];
2528 }
2529
2530 /**
2531 * Fully wipe one or more leads by email: every transcripts row, every related wp_options
2532 * entry (history, pre-chat capture, chat_deleted preservation, agent name, translations).
2533 *
2534 * Shared between the Leads-tab Delete button and the transcript-delete opt-in checkbox.
2535 * Input emails must already be sanitized with sanitize_email().
2536 */
2537 private static function mxchat_wipe_leads_by_email(array $emails) {
2538 global $wpdb;
2539 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2540 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
2541 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
2542
2543 $deleted_sessions = 0;
2544 $deleted_rows = 0;
2545 $emails_lc = array_map('strtolower', $emails);
2546
2547 foreach ($emails as $email) {
2548 $session_ids = $wpdb->get_col($wpdb->prepare(
2549 "SELECT DISTINCT session_id FROM {$table} WHERE user_email = %s",
2550 $email
2551 ));
2552
2553 $rows_removed = $wpdb->delete($table, ['user_email' => $email], ['%s']);
2554 if ($rows_removed !== false) {
2555 $deleted_rows += (int) $rows_removed;
2556 }
2557
2558 foreach ($session_ids as $sid) {
2559 $deleted_sessions++;
2560 wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions');
2561 delete_option('mxchat_history_' . $sid);
2562 delete_option('mxchat_email_' . $sid);
2563 delete_option('mxchat_name_' . $sid);
2564 delete_option('mxchat_agent_name_' . $sid);
2565 delete_option('mxchat_lead_del_email_' . $sid);
2566 delete_option('mxchat_lead_del_name_' . $sid);
2567 delete_option('mxchat_lead_del_ts_' . $sid);
2568 delete_option('mxchat_lead_del_consent_' . $sid);
2569 delete_option('mxchat_lead_del_consent_label_' . $sid);
2570 delete_option('mxchat_lead_del_consent_at_' . $sid);
2571 if (class_exists('MxChat_Session_Store')) {
2572 MxChat_Session_Store::delete_session($sid); // b64b77
2573 }
2574 if ($has_translations) {
2575 $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']);
2576 }
2577 }
2578 }
2579
2580 // Table-side twin of the option sweep below (5658f2): orphan pre-chat
2581 // captures now live in the sessions table's identity columns.
2582 if (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'find_by_email')) {
2583 foreach ($emails as $email) {
2584 foreach (MxChat_Session_Store::find_by_email($email) as $sid) {
2585 MxChat_Session_Store::delete_session($sid);
2586 }
2587 }
2588 }
2589
2590 // Clean up any lingering option entries (unmigrated orphan pre-chat captures +
2591 // chat_deleted preservations) whose stored value matches one of the emails being wiped.
2592 $lingering = $wpdb->get_results(
2593 "SELECT option_name, option_value FROM {$wpdb->options}
2594 WHERE option_name LIKE 'mxchat_email_%' OR option_name LIKE 'mxchat_lead_del_email_%'"
2595 );
2596 foreach ($lingering as $opt) {
2597 if (!in_array(strtolower(trim($opt->option_value)), $emails_lc, true)) {
2598 continue;
2599 }
2600 if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) {
2601 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2602 delete_option('mxchat_lead_del_email_' . $sid);
2603 delete_option('mxchat_lead_del_name_' . $sid);
2604 delete_option('mxchat_lead_del_ts_' . $sid);
2605 delete_option('mxchat_lead_del_consent_' . $sid);
2606 delete_option('mxchat_lead_del_consent_label_' . $sid);
2607 delete_option('mxchat_lead_del_consent_at_' . $sid);
2608 } else {
2609 $sid = substr($opt->option_name, strlen('mxchat_email_'));
2610 delete_option('mxchat_email_' . $sid);
2611 delete_option('mxchat_name_' . $sid);
2612 }
2613 }
2614
2615 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
2616
2617 return [
2618 'deleted_sessions' => $deleted_sessions,
2619 'deleted_rows' => $deleted_rows,
2620 ];
2621 }
2622
2623 /**
2624 * Stream a leads CSV. scope=all exports every lead under current filters is not
2625 * supported to keep semantics simple; caller either exports all leads or a
2626 * specific set of selected emails.
2627 */
2628 public function mxchat_export_leads() {
2629 if (!current_user_can('manage_options')) {
2630 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat'));
2631 }
2632 check_ajax_referer('mxchat_export_leads', 'security');
2633
2634 $scope = isset($_POST['scope']) ? sanitize_key($_POST['scope']) : 'all';
2635 $fields_mode = isset($_POST['fields']) ? sanitize_key($_POST['fields']) : 'email_and_name';
2636 // Selection keys arrive in the same shape the Leads tab renders them:
2637 // an address, or "session:<id>" for a name-only capture (c0cfaf).
2638 $selection_keys = self::mxchat_leads_split_keys(
2639 isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : []
2640 );
2641 $emails_in_clean = $selection_keys['emails'];
2642 $sessions_in = $selection_keys['sessions'];
2643
2644 global $wpdb;
2645 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2646 $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'"));
2647
2648 // Collect leads from transcripts. Keyed by email, or by session id when
2649 // the capture had no address (c0cfaf) — gated on the option, so with email
2650 // required this is the same query and the same rows as before.
2651 $lead_key_sql = self::mxchat_leads_key_sql();
2652 $email_filter = self::mxchat_leads_email_optional()
2653 ? "((user_email IS NOT NULL AND user_email != '') OR (user_name IS NOT NULL AND user_name != ''))"
2654 : "user_email IS NOT NULL AND user_email != ''";
2655
2656 $transcripts_sql = "SELECT {$lead_key_sql} AS lead_key,
2657 MAX(user_email) AS email,
2658 MAX(timestamp) AS last_seen,
2659 COUNT(DISTINCT session_id) AS conversation_count
2660 FROM {$table}
2661 WHERE {$email_filter}";
2662 $params = [];
2663 $selected_keys = array_merge(
2664 $emails_in_clean,
2665 array_map(function ($sid) { return 'session:' . $sid; }, $sessions_in)
2666 );
2667 if ($scope === 'selected' && !empty($selected_keys)) {
2668 $placeholders = implode(',', array_fill(0, count($selected_keys), '%s'));
2669 $transcripts_sql .= " AND {$lead_key_sql} IN ({$placeholders})";
2670 $params = $selected_keys;
2671 }
2672 $transcripts_sql .= " GROUP BY lead_key ORDER BY last_seen DESC";
2673
2674 $rows = !empty($params)
2675 ? $wpdb->get_results($wpdb->prepare($transcripts_sql, $params))
2676 : $wpdb->get_results($transcripts_sql);
2677
2678 // Hydrate each row with name + top page.
2679 $export_rows = [];
2680 foreach ($rows as $row) {
2681 $detail = $has_page_url_column
2682 ? $wpdb->get_row($wpdb->prepare(
2683 "SELECT user_name, originating_page_url FROM {$table}
2684 WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2685 $row->lead_key
2686 ))
2687 : $wpdb->get_row($wpdb->prepare(
2688 "SELECT user_name FROM {$table}
2689 WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1",
2690 $row->lead_key
2691 ));
2692 $export_rows[] = [
2693 'email' => (string) $row->email,
2694 'name' => isset($detail->user_name) ? (string) $detail->user_name : '',
2695 'conversation_count' => (int) $row->conversation_count,
2696 'last_seen' => $row->last_seen,
2697 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '',
2698 ];
2699 }
2700
2701 // Include orphan + chat_deleted leads when exporting all.
2702 if ($scope === 'all') {
2703 $transcripts_emails_lc = array_flip(array_map(
2704 function ($r) { return strtolower($r['email']); },
2705 $export_rows
2706 ));
2707 foreach (self::mxchat_collect_chat_deleted_leads('') as $cd) {
2708 if (isset($transcripts_emails_lc[strtolower($cd['email'])])) continue;
2709 $transcripts_emails_lc[strtolower($cd['email'])] = true;
2710 $export_rows[] = [
2711 'email' => $cd['email'],
2712 'name' => $cd['name'],
2713 'conversation_count' => 0,
2714 'last_seen' => $cd['last_seen'],
2715 'top_page_url' => '',
2716 'consent' => isset($cd['consent']) ? $cd['consent'] : '',
2717 'consent_at' => isset($cd['consent_at']) ? $cd['consent_at'] : '',
2718 'consent_label' => isset($cd['consent_label']) ? $cd['consent_label'] : '',
2719 ];
2720 }
2721 foreach (self::mxchat_collect_orphan_leads('') as $orphan) {
2722 if (isset($transcripts_emails_lc[strtolower($orphan['email'])])) continue;
2723 $transcripts_emails_lc[strtolower($orphan['email'])] = true;
2724 $export_rows[] = [
2725 'email' => $orphan['email'],
2726 'name' => $orphan['name'],
2727 'conversation_count' => 0,
2728 'last_seen' => '',
2729 'top_page_url' => '',
2730 ];
2731 }
2732 }
2733
2734 if (empty($export_rows)) {
2735 wp_send_json_error(['message' => 'No leads to export.']);
2736 wp_die();
2737 }
2738
2739 // Consent columns (b062c4). Chat-deleted rows already carry the preserved
2740 // values; the rest read the newest session-store record for that email.
2741 // Empty consent = "not recorded" — a pre-feature capture, NOT a decline.
2742 $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent');
2743 foreach ($export_rows as &$export_consent_ref) {
2744 if (array_key_exists('consent', $export_consent_ref)) {
2745 continue;
2746 }
2747 $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($export_consent_ref['email']) : null;
2748 $export_consent_ref['consent'] = $consent ? $consent['given'] : '';
2749 $export_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : '';
2750 $export_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : '';
2751 }
2752 unset($export_consent_ref);
2753
2754 $filename = 'mxchat-leads-' . date('Y-m-d') . '.csv';
2755 header('Content-Type: text/csv');
2756 header('Content-Disposition: attachment; filename="' . $filename . '"');
2757 header('Pragma: no-cache');
2758 header('Expires: 0');
2759
2760 $output = fopen('php://output', 'w');
2761 fputs($output, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel
2762
2763 if ($fields_mode === 'email_only') {
2764 fputcsv($output, ['Email']);
2765 foreach ($export_rows as $r) {
2766 fputcsv($output, [$r['email']]);
2767 }
2768 } else {
2769 fputcsv($output, ['Email', 'Name', 'Conversations', 'Last seen', 'Top page', 'Consent', 'Consent recorded', 'Consent text shown']);
2770 foreach ($export_rows as $r) {
2771 fputcsv($output, [
2772 $r['email'],
2773 $r['name'],
2774 $r['conversation_count'],
2775 $r['last_seen'],
2776 $r['top_page_url'],
2777 $r['consent'] !== '' ? $r['consent'] : 'not recorded',
2778 $r['consent_at'],
2779 $r['consent_label'],
2780 ]);
2781 }
2782 }
2783
2784 fclose($output);
2785 wp_die();
2786 }
2787
2788 /**
2789 * Stats strip payload (independent of filters).
2790 */
2791 private static function mxchat_leads_stats($table, $has_page_url_column) {
2792 global $wpdb;
2793
2794 $total_transcripts_emails = (int) $wpdb->get_var(
2795 "SELECT COUNT(DISTINCT user_email) FROM {$table}
2796 WHERE user_email IS NOT NULL AND user_email != ''"
2797 );
2798
2799 $new_this_week = (int) $wpdb->get_var($wpdb->prepare(
2800 "SELECT COUNT(*) FROM (
2801 SELECT user_email FROM {$table}
2802 WHERE user_email IS NOT NULL AND user_email != ''
2803 GROUP BY user_email
2804 HAVING MIN(timestamp) >= %s
2805 ) AS new_leads",
2806 gmdate('Y-m-d H:i:s', strtotime('-7 days'))
2807 ));
2808
2809 $total_convos = (int) $wpdb->get_var(
2810 "SELECT COUNT(DISTINCT session_id) FROM {$table}
2811 WHERE user_email IS NOT NULL AND user_email != ''"
2812 );
2813
2814 $orphan_count = count(self::mxchat_collect_orphan_leads(''));
2815 $chat_deleted_count = self::mxchat_count_chat_deleted_leads();
2816
2817 // Total leads = unique emails across all three sources (dedup priority: transcripts > chat_deleted > orphan
2818 // is already enforced at collection time in mxchat_fetch_leads; stats re-apply it here).
2819 $total_leads = $total_transcripts_emails + $chat_deleted_count + $orphan_count;
2820
2821 $avg = $total_transcripts_emails > 0
2822 ? round($total_convos / $total_transcripts_emails, 1)
2823 : 0;
2824
2825 // Orphan % reflects *true* orphans only (pre-chat dropoffs). Chat-deleted leads are
2826 // excluded so the metric stays meaningful — admins shouldn't see their cleanups
2827 // inflate this number.
2828 $orphan_pct = $total_leads > 0
2829 ? (int) round(($orphan_count / $total_leads) * 100)
2830 : 0;
2831
2832 return [
2833 'total_leads' => $total_leads,
2834 'new_this_week' => $new_this_week,
2835 'avg_convos' => $avg,
2836 'orphan_pct' => $orphan_pct,
2837 'orphan_count' => $orphan_count,
2838 'chat_deleted_count' => $chat_deleted_count,
2839 ];
2840 }
2841
2842 /**
2843 * Collect leads who had a conversation that an admin later deleted (preserved via
2844 * mxchat_lead_del_* options). Returns rows tagged status='chat_deleted' with the
2845 * original last-seen timestamp so they still sort and filter sensibly.
2846 *
2847 * @param string $search Optional email/name substring filter.
2848 * @param string $date_cutoff Optional 'Y-m-d H:i:s' cutoff — only rows with last_ts >= cutoff.
2849 * @return array
2850 */
2851 private static function mxchat_collect_chat_deleted_leads($search = '', $date_cutoff = '') {
2852 global $wpdb;
2853 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2854
2855 $rows = $wpdb->get_results(
2856 "SELECT option_name, option_value FROM {$wpdb->options}
2857 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2858 );
2859 if (empty($rows)) {
2860 return [];
2861 }
2862
2863 // Emails that currently have transcripts rows should not appear as chat_deleted —
2864 // they've come back and chatted, so they're active leads again.
2865 $emails_in_transcripts = array_map(
2866 'strtolower',
2867 (array) $wpdb->get_col(
2868 "SELECT DISTINCT user_email FROM {$table}
2869 WHERE user_email IS NOT NULL AND user_email != ''"
2870 )
2871 );
2872 $emails_in_transcripts = array_flip($emails_in_transcripts);
2873
2874 $needle = strtolower(trim((string) $search));
2875 $by_email = [];
2876
2877 foreach ($rows as $opt) {
2878 $email = sanitize_email(trim((string) $opt->option_value));
2879 if (!$email) {
2880 continue;
2881 }
2882 if (isset($emails_in_transcripts[strtolower($email)])) {
2883 continue;
2884 }
2885 $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_'));
2886 if (!$sid) {
2887 continue;
2888 }
2889 $name = (string) get_option('mxchat_lead_del_name_' . $sid, '');
2890 $ts = (string) get_option('mxchat_lead_del_ts_' . $sid, '');
2891
2892 if ($date_cutoff !== '' && ($ts === '' || $ts < $date_cutoff)) {
2893 continue;
2894 }
2895 if ($needle !== '') {
2896 $hay = strtolower($email . ' ' . $name);
2897 if (strpos($hay, $needle) === false) {
2898 continue;
2899 }
2900 }
2901
2902 $key = strtolower($email);
2903 if (!isset($by_email[$key]) || (isset($by_email[$key]['last_seen']) && $ts > $by_email[$key]['last_seen'])) {
2904 // Consent proof preserved at chat-delete time (b062c4) — the
2905 // session row is gone, so these options are the only copy.
2906 $consent_state = (string) get_option('mxchat_lead_del_consent_' . $sid, '');
2907
2908 $by_email[$key] = [
2909 'email' => $email,
2910 'name' => $name,
2911 'conversation_count' => 0,
2912 'last_seen' => $ts,
2913 'last_seen_display' => $ts ? self::mxchat_leads_format_relative($ts) : __('Chat deleted', 'mxchat'),
2914 'first_seen' => $ts,
2915 'latest_session_id' => '',
2916 'top_page_url' => '',
2917 'top_page_title' => '',
2918 'is_orphan' => false,
2919 'status' => 'chat_deleted',
2920 'consent' => $consent_state,
2921 'consent_at' => $consent_state !== '' ? (string) get_option('mxchat_lead_del_consent_at_' . $sid, '') : '',
2922 'consent_label' => $consent_state !== '' ? wp_strip_all_tags((string) get_option('mxchat_lead_del_consent_label_' . $sid, '')) : '',
2923 ];
2924 }
2925 }
2926
2927 // Newest chat_deleted first.
2928 usort($by_email, function ($a, $b) {
2929 return strcmp((string) $b['last_seen'], (string) $a['last_seen']);
2930 });
2931 return array_values($by_email);
2932 }
2933
2934 /**
2935 * Count unique emails preserved as "chat deleted" (for the stats strip).
2936 */
2937 private static function mxchat_count_chat_deleted_leads() {
2938 global $wpdb;
2939 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2940
2941 $emails = $wpdb->get_col(
2942 "SELECT DISTINCT option_value FROM {$wpdb->options}
2943 WHERE option_name LIKE 'mxchat_lead_del_email_%'"
2944 );
2945 if (empty($emails)) {
2946 return 0;
2947 }
2948
2949 $transcripts_emails = array_map(
2950 'strtolower',
2951 (array) $wpdb->get_col(
2952 "SELECT DISTINCT user_email FROM {$table}
2953 WHERE user_email IS NOT NULL AND user_email != ''"
2954 )
2955 );
2956 $transcripts_emails = array_flip($transcripts_emails);
2957
2958 $count = 0;
2959 $seen = [];
2960 foreach ($emails as $raw) {
2961 $email = strtolower(trim((string) $raw));
2962 if (!$email || isset($seen[$email]) || isset($transcripts_emails[$email])) {
2963 continue;
2964 }
2965 $seen[$email] = true;
2966 $count++;
2967 }
2968 return $count;
2969 }
2970
2971 /**
2972 * Find leads who submitted the pre-chat form but never produced a transcripts row.
2973 * Returned rows have no conversation_count, no timestamp.
2974 *
2975 * @param string $search Optional email/name substring filter.
2976 * @return array
2977 */
2978 private static function mxchat_collect_orphan_leads($search = '') {
2979 global $wpdb;
2980 $table = $wpdb->prefix . 'mxchat_chat_transcripts';
2981
2982 // Primary source since 5658f2: the sessions table's identity columns.
2983 // The legacy option scan stays for installs still mid-migration.
2984 // Ask for name-only sessions too when the owner made email optional
2985 // (c0cfaf) — that is the only source an address-less lead has.
2986 $store_rows = (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'identity_rows'))
2987 ? MxChat_Session_Store::identity_rows(self::mxchat_leads_email_optional())
2988 : [];
2989
2990 $option_rows = $wpdb->get_results(
2991 "SELECT option_name, option_value FROM {$wpdb->options}
2992 WHERE option_name LIKE 'mxchat_email_%'"
2993 );
2994 if (empty($option_rows) && empty($store_rows)) {
2995 return [];
2996 }
2997
2998 // Collect all session_ids that have real transcripts rows so we can exclude them.
2999 $session_ids_with_rows = $wpdb->get_col(
3000 "SELECT DISTINCT session_id FROM {$table}
3001 WHERE user_email IS NOT NULL AND user_email != ''"
3002 );
3003 $session_ids_with_rows = array_flip($session_ids_with_rows);
3004
3005 // Seen emails in transcripts (so orphans only include truly never-chatted leads).
3006 $emails_in_transcripts = array_map(
3007 'strtolower',
3008 (array) $wpdb->get_col(
3009 "SELECT DISTINCT user_email FROM {$table}
3010 WHERE user_email IS NOT NULL AND user_email != ''"
3011 )
3012 );
3013 $emails_in_transcripts = array_flip($emails_in_transcripts);
3014
3015 $orphans_by_email = [];
3016 $needle = strtolower(trim((string) $search));
3017
3018 // Normalize both sources into [sid, email, name] candidates. Table rows
3019 // first — a session can only live in one home, and dedup-by-email below
3020 // handles any overlap either way.
3021 $candidates = [];
3022 foreach ($store_rows as $row) {
3023 $candidates[] = [
3024 'sid' => (string) $row['session_id'],
3025 'email' => (string) $row['visitor_email'],
3026 'name' => is_string($row['visitor_name'] ?? null) ? $row['visitor_name'] : '',
3027 ];
3028 }
3029 foreach ($option_rows as $opt) {
3030 $sid = substr($opt->option_name, strlen('mxchat_email_'));
3031 $name_option = $sid ? get_option('mxchat_name_' . $sid, '') : '';
3032 $candidates[] = [
3033 'sid' => (string) $sid,
3034 'email' => (string) $opt->option_value,
3035 'name' => is_string($name_option) ? $name_option : '',
3036 ];
3037 }
3038
3039 // A capture with no address is an orphan too, and the commonest one: a
3040 // name-only lead (c0cfaf) has no transcript row until the visitor actually
3041 // sends a message, so this collector is the ONLY place it can surface.
3042 // Keying it on email would drop it silently — collected, then invisible.
3043 $email_optional = self::mxchat_leads_email_optional();
3044
3045 foreach ($candidates as $cand) {
3046 $email = sanitize_email(trim($cand['email']));
3047 $name = trim($cand['name']);
3048 $sid = $cand['sid'];
3049 if (!$sid) {
3050 continue;
3051 }
3052 if (!$email) {
3053 // Address-less rows are only leads when the owner made email
3054 // optional; otherwise they are incomplete captures, and skipping
3055 // them keeps this list identical to what it has always shown.
3056 if (!$email_optional || $name === '') {
3057 continue;
3058 }
3059 }
3060 // Exclude leads who have any transcripts rows (they appear in the main list).
3061 if ($email && isset($emails_in_transcripts[strtolower($email)])) {
3062 continue;
3063 }
3064 if (isset($session_ids_with_rows[$sid])) {
3065 continue;
3066 }
3067
3068 if ($needle !== '') {
3069 $hay = strtolower($email . ' ' . $name);
3070 if (strpos($hay, $needle) === false) {
3071 continue;
3072 }
3073 }
3074
3075 $key = $email !== '' ? strtolower($email) : 'session:' . $sid;
3076 if (!isset($orphans_by_email[$key])) {
3077 $orphans_by_email[$key] = [
3078 'lead_key' => $key,
3079 'email' => $email,
3080 'name' => $name,
3081 'conversation_count' => 0,
3082 'last_seen' => '',
3083 'last_seen_display' => __('No conversation yet', 'mxchat'),
3084 'first_seen' => '',
3085 // An address-less orphan carries its session id: that is the
3086 // only handle the delete and export paths have on it. Rows
3087 // that DO have an address keep the empty value they always
3088 // had, so the "View convo" button stays hidden for them.
3089 'latest_session_id' => $email !== '' ? '' : $sid,
3090 'top_page_url' => '',
3091 'top_page_title' => '',
3092 'is_orphan' => true,
3093 'status' => 'orphan',
3094 ];
3095 }
3096 }
3097
3098 return array_values($orphans_by_email);
3099 }
3100
3101 /**
3102 * Is the pre-chat email field optional? (c0cfaf)
3103 *
3104 * When it is, a lead can exist with a name and no address, and every place
3105 * that treats email as the row key has to widen. Default ON — an install that
3106 * never touched the setting behaves exactly as it did before.
3107 */
3108 private static function mxchat_leads_email_optional() {
3109 $options = get_option('mxchat_options', []);
3110 return is_array($options) && ($options['lead_capture_require_email_toggle'] ?? 'on') !== 'on';
3111 }
3112
3113 /**
3114 * SQL expression for a lead's key: its email address, or "session:<id>" when
3115 * the capture had no address (c0cfaf).
3116 *
3117 * The literal prefix is what keeps the two key spaces apart — an address can
3118 * never contain a colon before its local part, so a session key can never be
3119 * mistaken for an email or vice versa. NULLIF collapses '' and NULL to the
3120 * same thing, since the column uses both for "no email given".
3121 *
3122 * Contains no user input: safe to interpolate into a prepared statement.
3123 */
3124 private static function mxchat_leads_key_sql() {
3125 return "COALESCE(NULLIF(user_email, ''), CONCAT('session:', session_id))";
3126 }
3127
3128 /**
3129 * The PHP-side twin of mxchat_leads_key_sql(), used to dedupe rows arriving
3130 * from the three lead sources. Email keys stay lowercased, exactly as before,
3131 * so dedup behaviour for addressed leads is unchanged.
3132 */
3133 private static function mxchat_leads_dedup_key($row) {
3134 $email = isset($row['email']) ? trim((string) $row['email']) : '';
3135 if ($email !== '') {
3136 return strtolower($email);
3137 }
3138 if (!empty($row['lead_key'])) {
3139 return (string) $row['lead_key'];
3140 }
3141 return 'session:' . (isset($row['latest_session_id']) ? (string) $row['latest_session_id'] : '');
3142 }
3143
3144 /**
3145 * Split a list of client-supplied lead keys into real email addresses and
3146 * session keys. The Leads tab sends back whatever key it was given, so both
3147 * shapes arrive on the same parameter (c0cfaf).
3148 *
3149 * Anything that is neither a valid address nor a well-formed session key is
3150 * dropped — in particular an empty string, which as a bare email would match
3151 * every address-less row in the table at once.
3152 */
3153 private static function mxchat_leads_split_keys(array $raw) {
3154 $emails = [];
3155 $sessions = [];
3156 foreach ($raw as $value) {
3157 $value = trim((string) $value);
3158 if ($value === '') {
3159 continue;
3160 }
3161 if (strpos($value, 'session:') === 0) {
3162 $sid = MxChat_Utils::sanitize_session_id(substr($value, 8));
3163 if ($sid !== '' && $sid !== 'null') {
3164 $sessions[] = $sid;
3165 }
3166 continue;
3167 }
3168 $clean = sanitize_email($value);
3169 if ($clean) {
3170 $emails[] = $clean;
3171 }
3172 }
3173 return ['emails' => array_values(array_unique($emails)), 'sessions' => array_values(array_unique($sessions))];
3174 }
3175
3176 /**
3177 * Map a date_range key to a SQL-comparable cutoff string, or '' for all-time.
3178 */
3179 private static function mxchat_leads_date_cutoff($date_range) {
3180 switch ($date_range) {
3181 case 'today': return gmdate('Y-m-d H:i:s', strtotime('-24 hours'));
3182 case '7d': return gmdate('Y-m-d H:i:s', strtotime('-7 days'));
3183 case '30d': return gmdate('Y-m-d H:i:s', strtotime('-30 days'));
3184 case '90d': return gmdate('Y-m-d H:i:s', strtotime('-90 days'));
3185 case 'all':
3186 default: return '';
3187 }
3188 }
3189
3190 /**
3191 * Turn a UTC timestamp into a short relative display like "2h ago" or "Apr 12".
3192 */
3193 private static function mxchat_leads_format_relative($timestamp) {
3194 if (!$timestamp) {
3195 return '';
3196 }
3197 $ts = strtotime($timestamp . ' UTC');
3198 if (!$ts) {
3199 return '';
3200 }
3201 $diff = time() - $ts;
3202 if ($diff < 60) return __('just now', 'mxchat');
3203 if ($diff < 3600) return floor($diff / 60) . __('m ago', 'mxchat');
3204 if ($diff < 86400) return floor($diff / 3600) . __('h ago', 'mxchat');
3205 if ($diff < 604800) return floor($diff / 86400) . __('d ago', 'mxchat');
3206 return wp_date('M j', $ts);
3207 }
3208
3209 /**
3210 * Handle translation of chat messages via AJAX
3211 */
3212 public function mxchat_translate_messages() {
3213 if (!current_user_can('manage_options')) {
3214 wp_send_json_error(['error' => 'Insufficient permissions']);
3215 wp_die();
3216 }
3217
3218 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3219 $target_lang = isset($_POST['target_lang']) ? sanitize_text_field($_POST['target_lang']) : 'en';
3220 $messages_json = isset($_POST['messages']) ? wp_unslash($_POST['messages']) : '[]';
3221 $messages = json_decode($messages_json, true);
3222
3223 if (empty($session_id)) {
3224 wp_send_json_error(['error' => 'No session ID provided']);
3225 wp_die();
3226 }
3227
3228 if (empty($messages) || !is_array($messages)) {
3229 wp_send_json_error(['error' => 'No messages to translate']);
3230 wp_die();
3231 }
3232
3233 // Language names for prompting
3234 $languages = [
3235 'en' => 'English',
3236 'es' => 'Spanish',
3237 'fr' => 'French',
3238 'de' => 'German',
3239 'it' => 'Italian',
3240 'pt' => 'Portuguese',
3241 'nl' => 'Dutch',
3242 'ru' => 'Russian',
3243 'zh' => 'Chinese',
3244 'ja' => 'Japanese',
3245 'ko' => 'Korean',
3246 'ar' => 'Arabic',
3247 'hi' => 'Hindi',
3248 'tr' => 'Turkish',
3249 'pl' => 'Polish',
3250 'vi' => 'Vietnamese',
3251 'th' => 'Thai',
3252 'id' => 'Indonesian',
3253 'sv' => 'Swedish',
3254 'da' => 'Danish'
3255 ];
3256
3257 $target_lang_name = isset($languages[$target_lang]) ? $languages[$target_lang] : 'English';
3258
3259 // Build combined text for translation (numbered for parsing)
3260 $numbered_messages = [];
3261 foreach ($messages as $i => $msg) {
3262 $content = isset($msg['content']) ? trim($msg['content']) : '';
3263 if (!empty($content)) {
3264 $numbered_messages[] = "[MSG" . $i . "]" . $content . "[/MSG" . $i . "]";
3265 }
3266 }
3267
3268 if (empty($numbered_messages)) {
3269 wp_send_json_error(['error' => 'No valid messages to translate']);
3270 wp_die();
3271 }
3272
3273 $combined_text = implode("\n\n", $numbered_messages);
3274
3275 // Prepare the translation prompt
3276 $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.";
3277
3278 // Get user's selected model and determine provider
3279 $options = get_option('mxchat_options', []);
3280 $selected_model = $options['model'] ?? 'gpt-5.6-sol';
3281
3282 // Check if using OpenRouter
3283 if ($selected_model === 'openrouter') {
3284 $provider = 'openrouter';
3285 $selected_model = $options['openrouter_selected_model'] ?? '';
3286 $api_key = $options['openrouter_api_key'] ?? '';
3287
3288 if (empty($selected_model)) {
3289 wp_send_json_error(['error' => 'No OpenRouter model selected']);
3290 wp_die();
3291 }
3292 } else {
3293 // Determine provider from model name
3294 $model_parts = explode('-', $selected_model);
3295 $provider = strtolower($model_parts[0]);
3296
3297 // Get the appropriate API key based on provider
3298 $api_key = '';
3299 switch ($provider) {
3300 case 'gpt':
3301 case 'o1':
3302 $api_key = $options['api_key'] ?? '';
3303 break;
3304 case 'claude':
3305 $api_key = $options['claude_api_key'] ?? '';
3306 break;
3307 case 'grok':
3308 $api_key = $options['xai_api_key'] ?? '';
3309 break;
3310 case 'deepseek':
3311 $api_key = $options['deepseek_api_key'] ?? '';
3312 break;
3313 case 'gemini':
3314 $api_key = $options['gemini_api_key'] ?? '';
3315 break;
3316 default:
3317 // Default to OpenAI
3318 $api_key = $options['api_key'] ?? '';
3319 $provider = 'gpt';
3320 break;
3321 }
3322 }
3323
3324 if (empty($api_key)) {
3325 wp_send_json_error(['error' => 'No API key configured for ' . $provider]);
3326 wp_die();
3327 }
3328
3329 // Make API request based on provider
3330 $response = $this->translate_with_provider($provider, $selected_model, $api_key, $system_prompt, $combined_text);
3331
3332 if (is_wp_error($response)) {
3333 wp_send_json_error(['error' => $response->get_error_message()]);
3334 wp_die();
3335 }
3336
3337 // Parse the response to extract translated messages
3338 $translations = [];
3339 foreach ($messages as $msg) {
3340 $index = $msg['index'];
3341 $pattern = '/\[MSG' . $index . '\](.*?)\[\/MSG' . $index . '\]/s';
3342 if (preg_match($pattern, $response, $matches)) {
3343 $translations[] = [
3344 'index' => $index,
3345 'translated' => trim($matches[1])
3346 ];
3347 }
3348 }
3349
3350 // Save translations to database
3351 if (!empty($translations)) {
3352 $this->save_transcript_translation($session_id, $target_lang, $translations);
3353 }
3354
3355 wp_send_json(['success' => true, 'translations' => $translations, 'language' => $target_lang]);
3356 wp_die();
3357 }
3358
3359 /**
3360 * Save transcript translation to database
3361 */
3362 private function save_transcript_translation($session_id, $language_code, $translations) {
3363 global $wpdb;
3364 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
3365
3366 // Check if table exists, create if not
3367 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
3368 mxchat_create_translations_table();
3369 }
3370
3371 $now = current_time('mysql');
3372 $translations_json = wp_json_encode($translations);
3373
3374 // Use REPLACE to insert or update
3375 $wpdb->query($wpdb->prepare(
3376 "REPLACE INTO $table_name (session_id, language_code, translations, created_at, updated_at)
3377 VALUES (%s, %s, %s, %s, %s)",
3378 $session_id,
3379 $language_code,
3380 $translations_json,
3381 $now,
3382 $now
3383 ));
3384 }
3385
3386 /**
3387 * Get saved translation for a session
3388 */
3389 public function mxchat_get_transcript_translation() {
3390 if (!current_user_can('manage_options')) {
3391 wp_send_json_error(['error' => 'Insufficient permissions']);
3392 wp_die();
3393 }
3394
3395 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3396
3397 if (empty($session_id)) {
3398 wp_send_json_error(['error' => 'No session ID provided']);
3399 wp_die();
3400 }
3401
3402 global $wpdb;
3403 $table_name = $wpdb->prefix . 'mxchat_transcript_translations';
3404
3405 // Check if table exists
3406 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) {
3407 wp_send_json(['success' => true, 'has_translation' => false]);
3408 wp_die();
3409 }
3410
3411 // Get the most recent translation for this session
3412 $result = $wpdb->get_row($wpdb->prepare(
3413 "SELECT language_code, translations FROM $table_name WHERE session_id = %s ORDER BY updated_at DESC LIMIT 1",
3414 $session_id
3415 ));
3416
3417 if ($result) {
3418 $translations = json_decode($result->translations, true);
3419 wp_send_json([
3420 'success' => true,
3421 'has_translation' => true,
3422 'language' => $result->language_code,
3423 'translations' => $translations
3424 ]);
3425 } else {
3426 wp_send_json(['success' => true, 'has_translation' => false]);
3427 }
3428 wp_die();
3429 }
3430
3431 /**
3432 * Translate text using the user's selected provider and model
3433 */
3434 private function translate_with_provider($provider, $model, $api_key, $system_prompt, $text) {
3435 switch ($provider) {
3436 case 'claude':
3437 return $this->translate_with_claude($api_key, $model, $system_prompt, $text);
3438 case 'grok':
3439 return $this->translate_with_xai($api_key, $model, $system_prompt, $text);
3440 case 'deepseek':
3441 return $this->translate_with_deepseek($api_key, $model, $system_prompt, $text);
3442 case 'gemini':
3443 return $this->translate_with_gemini($api_key, $model, $system_prompt, $text);
3444 case 'openrouter':
3445 return $this->translate_with_openrouter($api_key, $model, $system_prompt, $text);
3446 case 'gpt':
3447 case 'o1':
3448 default:
3449 return $this->translate_with_openai($api_key, $model, $system_prompt, $text);
3450 }
3451 }
3452
3453 /**
3454 * Translate text using OpenAI API
3455 */
3456 private function translate_with_openai($api_key, $model, $system_prompt, $text) {
3457 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [
3458 'timeout' => 60,
3459 'headers' => [
3460 'Authorization' => 'Bearer ' . $api_key,
3461 'Content-Type' => 'application/json'
3462 ],
3463 'body' => wp_json_encode([
3464 'model' => $model,
3465 'messages' => [
3466 ['role' => 'system', 'content' => $system_prompt],
3467 ['role' => 'user', 'content' => $text]
3468 ],
3469 'temperature' => 0.3
3470 ])
3471 ]);
3472
3473 if (is_wp_error($response)) {
3474 return $response;
3475 }
3476
3477 $body = json_decode(wp_remote_retrieve_body($response), true);
3478
3479 if (isset($body['error'])) {
3480 return new WP_Error('api_error', $body['error']['message']);
3481 }
3482
3483 if (isset($body['choices'][0]['message']['content'])) {
3484 return $body['choices'][0]['message']['content'];
3485 }
3486
3487 return new WP_Error('api_error', 'Invalid API response');
3488 }
3489
3490 /**
3491 * Translate text using Claude API
3492 */
3493 private function translate_with_claude($api_key, $model, $system_prompt, $text) {
3494 // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3495 // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3496 if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3497 elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3498 $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
3499 'timeout' => 60,
3500 'headers' => [
3501 'x-api-key' => $api_key,
3502 'anthropic-version' => '2023-06-01',
3503 'Content-Type' => 'application/json'
3504 ],
3505 'body' => wp_json_encode([
3506 'model' => $model,
3507 'max_tokens' => 4096,
3508 // Prompt-cache breakpoint (plan 1ff43b): the translate instruction
3509 // repeats across every message in a batch, so cache reads bill at
3510 // 0.1x. Below the model's minimum prefix it silently no-ops.
3511 'system' => trim((string) $system_prompt) === '' ? $system_prompt : [
3512 ['type' => 'text', 'text' => $system_prompt, 'cache_control' => ['type' => 'ephemeral']]
3513 ],
3514 'messages' => [
3515 ['role' => 'user', 'content' => $text]
3516 ]
3517 ])
3518 ]);
3519
3520 if (is_wp_error($response)) {
3521 return $response;
3522 }
3523
3524 $body = json_decode(wp_remote_retrieve_body($response), true);
3525
3526 if (isset($body['error'])) {
3527 return new WP_Error('api_error', $body['error']['message']);
3528 }
3529
3530 if (isset($body['content'][0]['text'])) {
3531 return $body['content'][0]['text'];
3532 }
3533
3534 return new WP_Error('api_error', 'Invalid API response');
3535 }
3536
3537 /**
3538 * Translate text using xAI (Grok) API
3539 */
3540 private function translate_with_xai($api_key, $model, $system_prompt, $text) {
3541 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', [
3542 'timeout' => 60,
3543 'headers' => [
3544 'Authorization' => 'Bearer ' . $api_key,
3545 'Content-Type' => 'application/json'
3546 ],
3547 'body' => wp_json_encode([
3548 'model' => $model,
3549 'messages' => [
3550 ['role' => 'system', 'content' => $system_prompt],
3551 ['role' => 'user', 'content' => $text]
3552 ],
3553 'temperature' => 0.3
3554 ])
3555 ]);
3556
3557 if (is_wp_error($response)) {
3558 return $response;
3559 }
3560
3561 $body = json_decode(wp_remote_retrieve_body($response), true);
3562
3563 if (isset($body['error'])) {
3564 return new WP_Error('api_error', $body['error']['message']);
3565 }
3566
3567 if (isset($body['choices'][0]['message']['content'])) {
3568 return $body['choices'][0]['message']['content'];
3569 }
3570
3571 return new WP_Error('api_error', 'Invalid API response');
3572 }
3573
3574 /**
3575 * Translate text using DeepSeek API
3576 */
3577 private function translate_with_deepseek($api_key, $model, $system_prompt, $text) {
3578 $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', [
3579 'timeout' => 60,
3580 'headers' => [
3581 'Authorization' => 'Bearer ' . $api_key,
3582 'Content-Type' => 'application/json'
3583 ],
3584 'body' => wp_json_encode([
3585 'model' => $model,
3586 'messages' => [
3587 ['role' => 'system', 'content' => $system_prompt],
3588 ['role' => 'user', 'content' => $text]
3589 ],
3590 'temperature' => 0.3,
3591 // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3592 // slow responses); translation wants non-thinking.
3593 'thinking' => ['type' => 'disabled']
3594 ])
3595 ]);
3596
3597 if (is_wp_error($response)) {
3598 return $response;
3599 }
3600
3601 $body = json_decode(wp_remote_retrieve_body($response), true);
3602
3603 if (isset($body['error'])) {
3604 return new WP_Error('api_error', $body['error']['message']);
3605 }
3606
3607 if (isset($body['choices'][0]['message']['content'])) {
3608 return $body['choices'][0]['message']['content'];
3609 }
3610
3611 return new WP_Error('api_error', 'Invalid API response');
3612 }
3613
3614 /**
3615 * Translate text using Google Gemini API
3616 */
3617 private function translate_with_gemini($api_key, $model, $system_prompt, $text) {
3618 if ($model === 'gemini-3-pro-preview') {
3619 $model = 'gemini-3.1-pro-preview';
3620 }
3621 $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key;
3622
3623 $response = wp_remote_post($url, [
3624 'timeout' => 60,
3625 'headers' => [
3626 'Content-Type' => 'application/json'
3627 ],
3628 'body' => wp_json_encode([
3629 'contents' => [
3630 [
3631 'parts' => [
3632 ['text' => $system_prompt . "\n\n" . $text]
3633 ]
3634 ]
3635 ],
3636 'generationConfig' => [
3637 'temperature' => 0.3
3638 ]
3639 ])
3640 ]);
3641
3642 if (is_wp_error($response)) {
3643 return $response;
3644 }
3645
3646 $body = json_decode(wp_remote_retrieve_body($response), true);
3647
3648 if (isset($body['error'])) {
3649 return new WP_Error('api_error', $body['error']['message']);
3650 }
3651
3652 if (isset($body['candidates'][0]['content']['parts'][0]['text'])) {
3653 return $body['candidates'][0]['content']['parts'][0]['text'];
3654 }
3655
3656 return new WP_Error('api_error', 'Invalid API response');
3657 }
3658
3659 /**
3660 * Translate text using OpenRouter API
3661 */
3662 private function translate_with_openrouter($api_key, $model, $system_prompt, $text) {
3663 $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [
3664 'timeout' => 60,
3665 'headers' => [
3666 'Authorization' => 'Bearer ' . $api_key,
3667 'Content-Type' => 'application/json',
3668 'HTTP-Referer' => home_url(),
3669 'X-Title' => 'MxChat Translation'
3670 ],
3671 'body' => wp_json_encode([
3672 'model' => $model,
3673 'messages' => [
3674 ['role' => 'system', 'content' => $system_prompt],
3675 ['role' => 'user', 'content' => $text]
3676 ],
3677 'temperature' => 0.3
3678 ])
3679 ]);
3680
3681 if (is_wp_error($response)) {
3682 return $response;
3683 }
3684
3685 $body = json_decode(wp_remote_retrieve_body($response), true);
3686
3687 if (isset($body['error'])) {
3688 return new WP_Error('api_error', $body['error']['message']);
3689 }
3690
3691 if (isset($body['choices'][0]['message']['content'])) {
3692 return $body['choices'][0]['message']['content'];
3693 }
3694
3695 return new WP_Error('api_error', 'Invalid API response');
3696 }
3697
3698 public function mxchat_fetch_chat_history() {
3699 global $wpdb;
3700 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3701 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3702
3703 if (!current_user_can('manage_options')) {
3704 wp_send_json_error(['message' => 'Insufficient permissions']);
3705 wp_die();
3706 }
3707
3708 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
3709 $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 50;
3710 $offset = ($page - 1) * $per_page;
3711 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
3712 $sort_raw = isset($_POST['sort_order']) ? sanitize_key($_POST['sort_order']) : 'desc';
3713 $allowed_sorts = array('asc', 'desc', 'rating_positive', 'rating_negative');
3714 if (!in_array($sort_raw, $allowed_sorts, true)) { $sort_raw = 'desc'; }
3715 $sort_order = ($sort_raw === 'asc') ? 'ASC' : 'DESC';
3716 $ratings_table = $wpdb->prefix . 'mxchat_session_ratings';
3717 $ratings_join = '';
3718 $rating_table_exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $ratings_table)) === $ratings_table);
3719 if ($rating_table_exists && ($sort_raw === 'rating_positive' || $sort_raw === 'rating_negative')) {
3720 // We sort by rating then recency. Wrap rating_value so NULL sorts last.
3721 $rating_dir = ($sort_raw === 'rating_positive') ? 'DESC' : 'ASC';
3722 $ratings_join = " LEFT JOIN {$ratings_table} r ON r.session_id = t.session_id ";
3723 }
3724
3725 // Build search condition
3726 $search_condition = '';
3727 $search_params = [];
3728 if (!empty($search)) {
3729 $search_condition = "WHERE (
3730 session_id LIKE %s
3731 OR user_email LIKE %s
3732 OR user_name LIKE %s
3733 OR user_identifier LIKE %s
3734 OR message LIKE %s
3735 )";
3736 $search_params = array_fill(0, 5, '%' . $wpdb->esc_like($search) . '%');
3737 }
3738
3739 // Get total count
3740 $count_query = !empty($search)
3741 ? $wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table_name} {$search_condition}", $search_params)
3742 : "SELECT COUNT(DISTINCT session_id) FROM {$table_name}";
3743 $total_sessions = (int) $wpdb->get_var($count_query);
3744
3745 // Get session IDs for current page. Default sort is recency; rating sorts join the ratings table
3746 // and order by rating value (NULLs last) with recency as tiebreaker.
3747 if ($ratings_join) {
3748 $order_by = "ORDER BY (r.rating_value IS NULL), r.rating_value {$rating_dir}, MAX(t.timestamp) DESC";
3749 } else {
3750 $order_by = "ORDER BY MAX(t.timestamp) {$sort_order}";
3751 }
3752 $session_query = !empty($search)
3753 ? $wpdb->prepare(
3754 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} {$search_condition}
3755 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3756 array_merge($search_params, [$per_page, $offset])
3757 )
3758 : $wpdb->prepare(
3759 "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join}
3760 GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d",
3761 $per_page, $offset
3762 );
3763 $session_ids = $wpdb->get_col($session_query);
3764
3765 $total_pages = ceil($total_sessions / $per_page);
3766
3767 if (empty($session_ids)) {
3768 wp_send_json([
3769 'success' => true,
3770 'sessions' => [],
3771 'page' => $page,
3772 'total_pages' => 0,
3773 'total_sessions' => 0,
3774 'showing_start' => 0,
3775 'showing_end' => 0
3776 ]);
3777 wp_die();
3778 }
3779
3780 // Check for optional columns/tables
3781 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3782 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3783
3784 // Batch-fetch session ratings for this page (plan-a5b006).
3785 $ratings_map = array();
3786 if ($rating_table_exists && !empty($session_ids)) {
3787 $placeholders = implode(',', array_fill(0, count($session_ids), '%s'));
3788 $rating_rows = $wpdb->get_results($wpdb->prepare(
3789 "SELECT session_id, rating_value, rating_feedback FROM {$ratings_table} WHERE session_id IN ($placeholders)",
3790 $session_ids
3791 ));
3792 foreach ($rating_rows as $row) {
3793 $ratings_map[$row->session_id] = array(
3794 'value' => (int) $row->rating_value,
3795 'feedback' => (string) $row->rating_feedback,
3796 );
3797 }
3798 }
3799
3800 // Build session list data
3801 $sessions = [];
3802 foreach ($session_ids as $session_id) {
3803 // Get session metadata
3804 $session_data = $originating_columns_exist
3805 ? $wpdb->get_row($wpdb->prepare(
3806 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3807 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3808 $session_id
3809 ))
3810 : $wpdb->get_row($wpdb->prepare(
3811 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3812 WHERE session_id = %s LIMIT 1",
3813 $session_id
3814 ));
3815
3816 // Get message count and latest timestamp
3817 $message_stats = $wpdb->get_row($wpdb->prepare(
3818 "SELECT COUNT(*) as count, MAX(timestamp) as latest FROM {$table_name} WHERE session_id = %s",
3819 $session_id
3820 ));
3821
3822 // Get first user message as preview
3823 $first_user_msg = $wpdb->get_var($wpdb->prepare(
3824 "SELECT message FROM {$table_name} WHERE session_id = %s AND role = 'user' ORDER BY timestamp ASC LIMIT 1",
3825 $session_id
3826 ));
3827 $preview = $first_user_msg ? wp_trim_words(wp_strip_all_tags(stripslashes($first_user_msg)), 12, '...') : 'No messages';
3828
3829 // Build display name
3830 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3831 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3832 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3833
3834 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3835 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3836
3837 // Format time - show relative for recent, date for older
3838 $timestamp = strtotime($message_stats->latest . ' UTC');
3839 $now = time();
3840 $diff = $now - $timestamp;
3841 if ($diff < 3600) {
3842 $time_display = floor($diff / 60) . 'm ago';
3843 } elseif ($diff < 86400) {
3844 $time_display = floor($diff / 3600) . 'h ago';
3845 } elseif ($diff < 604800) {
3846 $time_display = floor($diff / 86400) . 'd ago';
3847 } else {
3848 $time_display = wp_date('M j', $timestamp);
3849 }
3850
3851 // Get initials for avatar
3852 $initials = strtoupper(substr($display_name, 0, 2));
3853 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3854 $parts = explode(' ', $display_name);
3855 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3856 }
3857
3858 $rating_entry = isset($ratings_map[$session_id]) ? $ratings_map[$session_id] : null;
3859 $rating_value = $rating_entry ? $rating_entry['value'] : null;
3860 $rating_feedback = $rating_entry ? $rating_entry['feedback'] : '';
3861
3862 $sessions[] = [
3863 'session_id' => $session_id,
3864 'display_name' => $display_name,
3865 'display_sub' => $display_sub,
3866 'initials' => $initials,
3867 'preview' => $preview,
3868 'message_count' => (int) $message_stats->count,
3869 'time_display' => $time_display,
3870 'timestamp' => $message_stats->latest,
3871 'rating_value' => $rating_value,
3872 'rating_feedback' => $rating_feedback,
3873 ];
3874 }
3875
3876 wp_send_json([
3877 'success' => true,
3878 'sessions' => $sessions,
3879 'page' => $page,
3880 'total_pages' => $total_pages,
3881 'total_sessions' => $total_sessions,
3882 'showing_start' => $offset + 1,
3883 'showing_end' => min($offset + $per_page, $total_sessions)
3884 ]);
3885 wp_die();
3886 }
3887
3888 /**
3889 * Fetch single conversation details for split-panel view
3890 */
3891 public function mxchat_fetch_conversation() {
3892 global $wpdb;
3893 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
3894 $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks';
3895
3896 if (!current_user_can('manage_options')) {
3897 wp_send_json_error(['message' => 'Insufficient permissions']);
3898 wp_die();
3899 }
3900
3901 $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
3902 if (empty($session_id)) {
3903 wp_send_json_error(['message' => 'No session ID provided']);
3904 wp_die();
3905 }
3906
3907 // Check for optional columns/tables
3908 $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table;
3909 $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'"));
3910
3911 // Get session metadata
3912 $session_data = $originating_columns_exist
3913 ? $wpdb->get_row($wpdb->prepare(
3914 "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp
3915 FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1",
3916 $session_id
3917 ))
3918 : $wpdb->get_row($wpdb->prepare(
3919 "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name}
3920 WHERE session_id = %s LIMIT 1",
3921 $session_id
3922 ));
3923
3924 if (!$session_data) {
3925 wp_send_json_error(['message' => 'Session not found']);
3926 wp_die();
3927 }
3928
3929 // Get clicked URLs
3930 $clicked_urls = [];
3931 if ($url_table_exists) {
3932 $url_clicks = $wpdb->get_results($wpdb->prepare(
3933 "SELECT DISTINCT clicked_url FROM {$url_clicks_table}
3934 WHERE session_id = %s ORDER BY click_timestamp ASC",
3935 $session_id
3936 ));
3937 foreach ($url_clicks as $click) {
3938 $clicked_urls[] = $click->clicked_url;
3939 }
3940 }
3941
3942 // Get all messages
3943 $messages = $wpdb->get_results($wpdb->prepare(
3944 "SELECT * FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC",
3945 $session_id
3946 ));
3947
3948 // Build user info
3949 $user_email = !empty($session_data->user_email) ? $session_data->user_email : '';
3950 $user_name = !empty($session_data->user_name) ? $session_data->user_name : '';
3951 $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest';
3952
3953 $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier);
3954 $display_sub = $user_email ?: ('ID: ' . $user_identifier);
3955
3956 // Get initials
3957 $initials = strtoupper(substr($display_name, 0, 2));
3958 if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) {
3959 $parts = explode(' ', $display_name);
3960 $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1));
3961 }
3962
3963 // Page info
3964 $page_url = $originating_columns_exist && !empty($session_data->originating_page_url) ? $session_data->originating_page_url : '';
3965 $page_title = '';
3966 if ($page_url) {
3967 $page_title = !empty($session_data->originating_page_title) ? $session_data->originating_page_title : parse_url($page_url, PHP_URL_PATH);
3968 }
3969
3970 // Format messages for output
3971 $formatted_messages = [];
3972 foreach ($messages as $msg) {
3973 $is_user = ($msg->role === 'user');
3974 // Live-agent replies (Slack/Telegram handoff) persist with role 'agent'.
3975 // Kept OUT of $is_bot so the RAG Sources link below stays bot-only.
3976 $is_agent = ($msg->role === 'agent');
3977 $is_bot = ($msg->role === 'bot' || $msg->role === 'assistant');
3978
3979 $content = wp_kses(
3980 stripslashes($msg->message),
3981 [
3982 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
3983 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
3984 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []],
3985 'div' => ['class' => [], 'id' => [], 'data-nonce' => []],
3986 'img' => ['src' => [], 'alt' => [], 'class' => []],
3987 'h3' => ['class' => []],
3988 'h4' => ['class' => []],
3989 'button' => ['type' => [], 'class' => [], 'data-product-id' => [], 'data-nonce' => [], 'data-product-type' => [], 'data-original-text' => [], 'data-mxchat-action' => []],
3990 'select' => ['class' => [], 'data-attribute' => []],
3991 'option' => ['value' => []],
3992 'span' => ['class' => []],
3993 'del' => [], 'ins' => [],
3994 ]
3995 );
3996 $formatted_content = $this->format_transcript_message($content);
3997
3998 $has_rag = $is_bot && !empty($msg->rag_context);
3999
4000 $formatted_messages[] = [
4001 'id' => $msg->id,
4002 'role' => $msg->role,
4003 'is_user' => $is_user,
4004 'is_agent' => $is_agent,
4005 'is_bot' => $is_bot,
4006 'content' => $formatted_content,
4007 'timestamp' => wp_date('g:i A', strtotime($msg->timestamp . ' UTC')),
4008 'full_timestamp' => wp_date('F j, Y g:i A', strtotime($msg->timestamp . ' UTC')),
4009 'has_rag' => $has_rag
4010 ];
4011 }
4012
4013 // First message timestamp for "started" display
4014 $started = !empty($messages) ? wp_date('M j, Y g:i A', strtotime($messages[0]->timestamp . ' UTC')) : '-';
4015
4016 // Pull rating_feedback for this session (if any) so the details drawer can show it.
4017 $rating_feedback = '';
4018 $ratings_table_det = $wpdb->prefix . 'mxchat_session_ratings';
4019 if ($wpdb->get_var("SHOW TABLES LIKE '$ratings_table_det'") === $ratings_table_det) {
4020 $rating_feedback = (string) $wpdb->get_var($wpdb->prepare(
4021 "SELECT rating_feedback FROM {$ratings_table_det} WHERE session_id = %s LIMIT 1",
4022 $session_id
4023 ));
4024 }
4025
4026 wp_send_json([
4027 'success' => true,
4028 'session_id' => $session_id,
4029 'user' => [
4030 'name' => $display_name,
4031 'sub' => $display_sub,
4032 'initials' => $initials,
4033 'email' => $user_email,
4034 'identifier' => $user_identifier
4035 ],
4036 'page' => [
4037 'url' => $page_url,
4038 'title' => $page_title
4039 ],
4040 'clicked_urls' => $clicked_urls,
4041 'messages' => $formatted_messages,
4042 'message_count' => count($messages),
4043 'started' => $started,
4044 'rating_feedback' => $rating_feedback
4045 ]);
4046 wp_die();
4047 }
4048
4049
4050 /**
4051 * ALTERNATIVE: Simpler helper method using string replacement
4052 */
4053 private function highlight_clicked_links($message_content, $clicked_urls) {
4054 if (empty($clicked_urls)) {
4055 return wp_kses(
4056 $message_content,
4057 [
4058 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
4059 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
4060 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
4061 ]
4062 );
4063 }
4064
4065 // First apply standard sanitization
4066 $message_content = wp_kses(
4067 $message_content,
4068 [
4069 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [],
4070 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [],
4071 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []]
4072 ]
4073 );
4074
4075 // Process each clicked URL
4076 foreach ($clicked_urls as $clicked_url) {
4077 // Try multiple patterns to catch different link formats
4078 $patterns = [
4079 // Standard link format
4080 '/<a([^>]*href=["\']' . preg_quote($clicked_url, '/') . '["\'][^>]*)>/i',
4081 // Link with trailing slash
4082 '/<a([^>]*href=["\']' . preg_quote(rtrim($clicked_url, '/'), '/') . '\/?["\'][^>]*)>/i',
4083 // Encoded entities version
4084 '/<a([^>]*href=["\']' . preg_quote(htmlentities($clicked_url), '/') . '["\'][^>]*)>/i',
4085 ];
4086
4087 foreach ($patterns as $pattern) {
4088 if (preg_match($pattern, $message_content)) {
4089 $message_content = preg_replace_callback(
4090 $pattern,
4091 function($matches) {
4092 $full_match = $matches[0];
4093 $attributes = $matches[1];
4094
4095 // Check if it already has the class
4096 if (strpos($full_match, 'mxchat-clicked-link') !== false) {
4097 return $full_match;
4098 }
4099
4100 // Check if class attribute exists
4101 if (preg_match('/class=["\']([^"\']*)["\']/', $attributes, $class_matches)) {
4102 // Add to existing class
4103 $new_attributes = preg_replace(
4104 '/class=["\']([^"\']*)["\']/',
4105 'class="$1 mxchat-clicked-link"',
4106 $attributes
4107 );
4108 } else {
4109 // Add new class attribute
4110 $new_attributes = $attributes . ' class="mxchat-clicked-link"';
4111 }
4112
4113 // Add title if not present
4114 if (strpos($new_attributes, 'title=') === false) {
4115 $new_attributes .= ' title="User clicked this link"';
4116 }
4117
4118 return '<a' . $new_attributes . '>';
4119 },
4120 $message_content
4121 );
4122
4123 // If we found and replaced, break out of the patterns loop
4124 break;
4125 }
4126 }
4127 }
4128
4129 return $message_content;
4130 }
4131
4132 public function mxchat_create_prompts_page() {
4133 //error_log('=== DEBUG: mxchat_create_prompts_page started ===');
4134
4135 global $wpdb;
4136 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
4137
4138 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
4139 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
4140
4141 // Display success message if all prompts were deleted
4142 if (isset($_GET['all_deleted']) && $_GET['all_deleted'] === 'true') {
4143 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>';
4144 }
4145
4146 // Set up pagination, search query, and content type filter
4147 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field($_GET['_wpnonce']) : '';
4148 $search_query = (!empty($nonce) && wp_verify_nonce($nonce, 'mxchat_prompts_search_nonce') && isset($_GET['search'])) ? sanitize_text_field($_GET['search']) : '';
4149 $content_type_filter = isset($_GET['content_type']) ? sanitize_key($_GET['content_type']) : ''; // ADDED 2.5.6
4150 $current_page = isset($_GET['paged']) ? absint($_GET['paged']) : 1;
4151 $per_page = 25;
4152
4153 //error_log('DEBUG: Search query: ' . $search_query);
4154 //error_log('DEBUG: Content type filter: ' . $content_type_filter);
4155 //error_log('DEBUG: Current page: ' . $current_page);
4156 //error_log('DEBUG: Per page: ' . $per_page);
4157
4158 // ================================
4159 // MULTI-BOT CONFIGURATION
4160 // ================================
4161
4162 // Check for multi-bot and set up bot selection
4163 if (class_exists('MxChat_Multi_Bot_Manager')) {
4164 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
4165 $available_bots = $multi_bot_manager->get_available_bots();
4166
4167 // Get saved bot selection (user-specific first, then site-wide default)
4168 $user_id = get_current_user_id();
4169 $saved_bot_id = get_user_meta($user_id, 'mxchat_selected_knowledge_bot', true);
4170 if (empty($saved_bot_id)) {
4171 $saved_bot_id = get_option('mxchat_current_knowledge_bot', 'default');
4172 }
4173
4174 // Allow URL override but default to saved selection
4175 $current_bot_id = isset($_GET['bot_id']) ? sanitize_key($_GET['bot_id']) : $saved_bot_id;
4176 $multibot_active = true;
4177 } else {
4178 $current_bot_id = 'default';
4179 $multibot_active = false;
4180 }
4181
4182 // ================================
4183 // DATA SOURCE CONFIGURATION
4184 // ================================
4185
4186 // Get bot-specific Pinecone settings
4187 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($current_bot_id);
4188 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
4189 $pinecone_api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4190
4191 // Get OpenAI Vector Store settings
4192 $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
4193 $use_vectorstore = ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1';
4194
4195 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
4196 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Has API Key: ' . (!empty($pinecone_api_key) ? 'YES' : 'NO'));
4197 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Host: ' . ($pinecone_options['mxchat_pinecone_host'] ?? 'NOT SET'));
4198 //error_log('DEBUG: Bot ' . $current_bot_id . ' - Namespace: ' . ($pinecone_options['mxchat_pinecone_namespace'] ?? 'NOT SET'));
4199
4200 if ($use_pinecone && !empty($pinecone_api_key)) {
4201 //error_log('DEBUG: Using PINECONE data source with bot-specific config');
4202 // PINECONE DATA SOURCE
4203 $data_source = 'pinecone';
4204
4205 // IMPORTANT: Pass the bot-specific $pinecone_options, not default options!
4206 // UPDATED 2.5.6: Added content_type_filter parameter
4207 $records = $pinecone_manager->mxchat_fetch_pinecone_records($pinecone_options, $search_query, $current_page, $per_page, $current_bot_id, $content_type_filter);
4208
4209 // TEMPORARY DEBUG - Add this right after the fetch call
4210 //error_log('=== DEBUG: Bot switching issue ===');
4211 //error_log('Current bot ID: ' . $current_bot_id);
4212 //error_log('Use Pinecone: ' . ($use_pinecone ? 'YES' : 'NO'));
4213 //error_log('Records returned: ' . count($records['data'] ?? []));
4214 //error_log('Total records: ' . ($records['total'] ?? 0));
4215
4216 // Check first few records to see their bot_id
4217 if (!empty($records['data'])) {
4218 foreach (array_slice($records['data'], 0, 3) as $i => $record) {
4219 $record_bot_id = $record->bot_id ?? 'NOT_SET';
4220 //error_log('Record ' . ($i+1) . ' bot_id: ' . $record_bot_id . ', content preview: ' . substr($record->article_content ?? '', 0, 30) . '...');
4221 }
4222 }
4223 //error_log('=== END DEBUG ===');
4224
4225 $total_records = $records['total'] ?? 0;
4226 $prompts = $records['data'] ?? array();
4227 $total_in_database = $records['total_in_database'] ?? 0;
4228 $showing_recent_only = $records['showing_recent_only'] ?? false;
4229 // Plan dd6e10: over 500 vectors the total is the index/namespace vector
4230 // count and pages come from Pinecone's cursor — the template renders
4231 // Previous / Next instead of numbered pages on that path.
4232 $count_unit = $records['count_unit'] ?? '';
4233 $cursor_pagination = !empty($records['cursor_pagination']);
4234 $has_next = !empty($records['has_next']);
4235
4236 $total_pages = ceil($total_records / $per_page);
4237
4238 } else {
4239 //error_log('DEBUG: Using WORDPRESS DB data source');
4240 // WORDPRESS DB DATA SOURCE (your existing logic)
4241 $data_source = 'wordpress';
4242
4243 // Initialize these variables for WordPress DB
4244 $total_in_database = 0;
4245 $showing_recent_only = false;
4246 $count_unit = '';
4247 $cursor_pagination = false;
4248 $has_next = false;
4249
4250 $offset = ($current_page - 1) * $per_page;
4251
4252 // UPDATED 2.5.6: Build WHERE clause for search and content type filtering
4253 $where_clauses = array();
4254 $where_values = array();
4255
4256 if ($search_query) {
4257 $where_clauses[] = "article_content LIKE %s";
4258 $where_values[] = '%' . $wpdb->esc_like($search_query) . '%';
4259 }
4260
4261 if ($content_type_filter) {
4262 $where_clauses[] = "content_type = %s";
4263 $where_values[] = $content_type_filter;
4264 }
4265
4266 $sql_where = "";
4267 if (!empty($where_clauses)) {
4268 $sql_where = "WHERE " . implode(" AND ", $where_clauses);
4269 }
4270
4271 // UPDATED 2.6.3: Count unique entries (by source_url) instead of individual rows
4272 // This ensures pagination shows X entries per page, not X chunks
4273 // Entries with empty source_url are counted individually
4274 if (!empty($where_values)) {
4275 // Count unique source_urls + count of rows with empty source_url
4276 $count_query = $wpdb->prepare(
4277 "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} {$sql_where} AND source_url != '') +
4278 (SELECT COUNT(*) FROM {$table_name} {$sql_where} AND (source_url = '' OR source_url IS NULL))",
4279 array_merge($where_values, $where_values)
4280 );
4281 } else {
4282 $count_query = "SELECT (SELECT COUNT(DISTINCT source_url) FROM {$table_name} WHERE source_url != '') +
4283 (SELECT COUNT(*) FROM {$table_name} WHERE source_url = '' OR source_url IS NULL)";
4284 }
4285 $total_records = $wpdb->get_var($count_query);
4286 $total_pages = ceil($total_records / $per_page);
4287
4288 // UPDATED 2.6.3: Get unique source_urls for pagination, then fetch all their rows
4289 // Step 1: Get the source_urls for this page (distinct URLs ordered by latest timestamp)
4290 if (!empty($where_values)) {
4291 $urls_query = $wpdb->prepare(
4292 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name} {$sql_where}
4293 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
4294 array_merge($where_values, array($per_page, $offset))
4295 );
4296 } else {
4297 $urls_query = $wpdb->prepare(
4298 "SELECT source_url, MAX(timestamp) as latest_ts FROM {$table_name}
4299 GROUP BY source_url ORDER BY latest_ts DESC LIMIT %d OFFSET %d",
4300 $per_page, $offset
4301 );
4302 }
4303 $page_urls = $wpdb->get_results($urls_query);
4304
4305 // Step 2: Fetch all rows for these source_urls
4306 $prompts = array();
4307 if (!empty($page_urls)) {
4308 // Build URL order map to preserve newest-first ordering from step 1
4309 $url_order_map = array();
4310 $url_list = array();
4311 $has_empty_url = false;
4312 $order_index = 0;
4313 foreach ($page_urls as $url_row) {
4314 if (empty($url_row->source_url)) {
4315 $has_empty_url = true;
4316 $url_order_map['__empty__'] = $order_index++;
4317 } else {
4318 $url_list[] = $url_row->source_url;
4319 $url_order_map[$url_row->source_url] = $order_index++;
4320 }
4321 }
4322
4323 // Build query to fetch all rows for these URLs
4324 $url_conditions = array();
4325 $url_values = array();
4326
4327 if (!empty($url_list)) {
4328 $placeholders = implode(',', array_fill(0, count($url_list), '%s'));
4329 $url_conditions[] = "source_url IN ($placeholders)";
4330 $url_values = array_merge($url_values, $url_list);
4331 }
4332
4333 if ($has_empty_url) {
4334 $url_conditions[] = "(source_url = '' OR source_url IS NULL)";
4335 }
4336
4337 if (!empty($url_conditions)) {
4338 $url_where = "WHERE (" . implode(" OR ", $url_conditions) . ")";
4339
4340 // Add original filters back
4341 if (!empty($where_clauses)) {
4342 $url_where .= " AND " . implode(" AND ", $where_clauses);
4343 $url_values = array_merge($url_values, $where_values);
4344 }
4345
4346 if (!empty($url_values)) {
4347 $prompts_query = $wpdb->prepare(
4348 "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC",
4349 $url_values
4350 );
4351 } else {
4352 $prompts_query = "SELECT * FROM {$table_name} {$url_where} ORDER BY timestamp DESC";
4353 }
4354
4355 $prompts = $wpdb->get_results($prompts_query);
4356
4357 // Sort prompts by the original URL order (newest first), then by timestamp within each URL group
4358 usort($prompts, function($a, $b) use ($url_order_map) {
4359 $url_a = empty($a->source_url) ? '__empty__' : $a->source_url;
4360 $url_b = empty($b->source_url) ? '__empty__' : $b->source_url;
4361 $order_a = $url_order_map[$url_a] ?? PHP_INT_MAX;
4362 $order_b = $url_order_map[$url_b] ?? PHP_INT_MAX;
4363
4364 // First sort by URL order (newest URLs first)
4365 if ($order_a !== $order_b) {
4366 return $order_a - $order_b;
4367 }
4368
4369 // Within same URL, sort by timestamp DESC (newest chunks first)
4370 return strtotime($b->timestamp) - strtotime($a->timestamp);
4371 });
4372 }
4373 }
4374 }
4375
4376 // ================================
4377 // PAGINATION GENERATION
4378 // ================================
4379
4380 // Generate pagination links
4381 if ($total_pages > 1) {
4382 // Build a clean base URL with only necessary parameters
4383 $pagination_args = array('page' => 'mxchat-prompts');
4384
4385 // Preserve bot_id parameter if multi-bot is active
4386 if ($multibot_active && !empty($current_bot_id) && $current_bot_id !== 'default') {
4387 $pagination_args['bot_id'] = $current_bot_id;
4388 }
4389
4390 // Preserve search query and nonce if present
4391 if ($search_query) {
4392 $pagination_args['search'] = $search_query;
4393 if (!empty($nonce)) {
4394 $pagination_args['_wpnonce'] = $nonce;
4395 }
4396 }
4397
4398 // Preserve content type filter if present
4399 if ($content_type_filter) {
4400 $pagination_args['content_type'] = $content_type_filter;
4401 }
4402
4403 // Build clean base URL
4404 $base_url = add_query_arg($pagination_args, admin_url('admin.php'));
4405
4406 $page_links = paginate_links(array(
4407 'base' => $base_url . '%_%',
4408 'format' => '&paged=%#%',
4409 'prev_text' => __('&laquo; Previous', 'mxchat'),
4410 'next_text' => __('Next &raquo;', 'mxchat'),
4411 'total' => $total_pages,
4412 'current' => $current_page,
4413 'type' => 'plain',
4414 ));
4415 } else {
4416 $page_links = '';
4417 }
4418
4419 // ================================
4420 // PROCESSING STATUS RETRIEVAL
4421 // ================================
4422
4423 // Retrieve processing statuses using queue-based method
4424 $processing_statuses = $knowledge_manager->mxchat_get_processing_statuses();
4425 $pdf_status = $processing_statuses['pdf_status'];
4426 $sitemap_status = $processing_statuses['sitemap_status'];
4427 $is_processing = $processing_statuses['is_processing'];
4428
4429 //error_log('=== DEBUG: mxchat_create_prompts_page data preparation completed ===');
4430
4431 // ================================
4432 // RENDER PAGE WITH NEW SIDEBAR LAYOUT
4433 // ================================
4434
4435 // Include the new knowledge page template
4436 require_once plugin_dir_path(__FILE__) . 'admin-knowledge-page.php';
4437
4438 // Package all page data for the render function
4439 $page_data = array(
4440 'prompts' => $prompts,
4441 'total_records' => $total_records,
4442 'total_pages' => $total_pages,
4443 'current_page' => $current_page,
4444 'per_page' => $per_page,
4445 'page_links' => $page_links,
4446 'search_query' => $search_query,
4447 'content_type_filter' => $content_type_filter,
4448 'data_source' => $data_source,
4449 'use_pinecone' => $use_pinecone,
4450 'use_vectorstore' => $use_vectorstore,
4451 'multibot_active' => $multibot_active,
4452 'current_bot_id' => $current_bot_id,
4453 'pdf_status' => $pdf_status,
4454 'sitemap_status' => $sitemap_status,
4455 'is_processing' => $is_processing,
4456 'total_in_database' => $total_in_database ?? 0,
4457 'showing_recent_only' => $showing_recent_only ?? false,
4458 'count_unit' => $count_unit ?? '',
4459 'cursor_pagination' => $cursor_pagination ?? false,
4460 'has_next' => $has_next ?? false,
4461 );
4462
4463 // Render the new sidebar-based page
4464 mxchat_render_knowledge_page($this, $knowledge_manager, $page_data);
4465 }
4466
4467 /**
4468 * Get bot-specific Pinecone configuration
4469 * Used in the knowledge retrieval functions
4470 */
4471 private function get_bot_pinecone_config($bot_id = 'default') {
4472 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
4473
4474 // If default bot or multi-bot add-on not active, use default Pinecone config
4475 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
4476 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
4477 $addon_options = get_option('mxchat_pinecone_addon_options', array());
4478 $config = array(
4479 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
4480 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
4481 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
4482 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
4483 );
4484 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
4485 return $config;
4486 }
4487
4488 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
4489
4490 // Hook for multi-bot add-on to provide bot-specific Pinecone config
4491 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
4492
4493 if (!empty($bot_pinecone_config)) {
4494 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
4495 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
4496 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
4497 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
4498 } else {
4499 //error_log("MXCHAT DEBUG: Filter returned empty config!");
4500 }
4501
4502 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
4503 }
4504
4505 public function mxchat_delete_chat_history() {
4506 if (!current_user_can('manage_options')) {
4507 echo wp_json_encode(['error' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4508 wp_die();
4509 }
4510 check_ajax_referer('mxchat_delete_chat_history', 'security');
4511
4512 if (!isset($_POST['delete_session_ids']) || !is_array($_POST['delete_session_ids'])) {
4513 echo wp_json_encode(['error' => esc_html__('No chat sessions selected for deletion.', 'mxchat')]);
4514 wp_die();
4515 }
4516
4517 global $wpdb;
4518 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4519 $translations_table = $wpdb->prefix . 'mxchat_transcript_translations';
4520 $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table;
4521
4522 // When true, any lead attached to these sessions is fully wiped (all their sessions,
4523 // across the whole table). Default false: the chat rows go away but the lead is
4524 // preserved as a separate "chat deleted" lead in the Leads tab.
4525 $also_delete_lead = !empty($_POST['also_delete_lead']) && $_POST['also_delete_lead'] !== 'false';
4526
4527 $deleted_count = 0;
4528 $preserved_as_deleted_leads = 0;
4529 $emails_to_fully_wipe = [];
4530
4531 foreach ((array) $_POST['delete_session_ids'] as $session_id) {
4532 $session_id_sanitized = MxChat_Utils::sanitize_session_id($session_id);
4533 if ($session_id_sanitized === '') {
4534 continue;
4535 }
4536
4537 // Capture the lead info attached to this session *before* we delete the rows.
4538 $lead_row = $wpdb->get_row($wpdb->prepare(
4539 "SELECT user_email, user_name, MAX(timestamp) AS last_ts
4540 FROM {$table_name}
4541 WHERE session_id = %s AND user_email IS NOT NULL AND user_email != ''
4542 GROUP BY user_email, user_name
4543 ORDER BY last_ts DESC LIMIT 1",
4544 $session_id_sanitized
4545 ));
4546
4547 wp_cache_delete('chat_session_' . $session_id_sanitized, 'mxchat_chat_sessions');
4548 $wpdb->delete($table_name, ['session_id' => $session_id_sanitized]);
4549
4550 if ($has_translations) {
4551 $wpdb->delete($translations_table, ['session_id' => $session_id_sanitized]);
4552 }
4553
4554 // Read the consent record BEFORE delete_session() destroys the row —
4555 // a preserved "chat deleted" lead must keep its consent proof (b062c4).
4556 $consent_to_preserve = null;
4557 if ($lead_row && !empty($lead_row->user_email) && class_exists('MxChat_Session_Store')) {
4558 $consent_state = MxChat_Session_Store::get($session_id_sanitized, 'consent', '');
4559 if ($consent_state !== '' && $consent_state !== false) {
4560 $consent_to_preserve = [
4561 'given' => (string) $consent_state,
4562 'label' => (string) MxChat_Session_Store::get($session_id_sanitized, 'consent_label', ''),
4563 'at' => (string) MxChat_Session_Store::get($session_id_sanitized, 'consent_at', ''),
4564 ];
4565 }
4566 }
4567
4568 delete_option('mxchat_history_' . $session_id_sanitized);
4569 delete_option('mxchat_agent_name_' . $session_id_sanitized);
4570 if (class_exists('MxChat_Session_Store')) {
4571 MxChat_Session_Store::delete_session($session_id_sanitized); // b64b77
4572 }
4573
4574 if ($lead_row && !empty($lead_row->user_email)) {
4575 if ($also_delete_lead) {
4576 // Full-wipe requested — queue the email so that all their sessions and
4577 // related options get swept below. Also clear this session's pre-chat
4578 // capture options (they're no longer meaningful).
4579 $emails_to_fully_wipe[strtolower($lead_row->user_email)] = $lead_row->user_email;
4580 delete_option('mxchat_email_' . $session_id_sanitized);
4581 delete_option('mxchat_name_' . $session_id_sanitized);
4582 } else {
4583 // Preserve the lead in a "Chat deleted" state via distinct option keys so
4584 // they stay out of the orphan bucket (orphan = pre-chat form dropoff).
4585 update_option('mxchat_lead_del_email_' . $session_id_sanitized, $lead_row->user_email, false);
4586 if (!empty($lead_row->user_name)) {
4587 update_option('mxchat_lead_del_name_' . $session_id_sanitized, $lead_row->user_name, false);
4588 }
4589 if (!empty($lead_row->last_ts)) {
4590 update_option('mxchat_lead_del_ts_' . $session_id_sanitized, $lead_row->last_ts, false);
4591 }
4592 if ($consent_to_preserve !== null) {
4593 update_option('mxchat_lead_del_consent_' . $session_id_sanitized, $consent_to_preserve['given'], false);
4594 update_option('mxchat_lead_del_consent_label_' . $session_id_sanitized, $consent_to_preserve['label'], false);
4595 update_option('mxchat_lead_del_consent_at_' . $session_id_sanitized, $consent_to_preserve['at'], false);
4596 }
4597 // Clean up pre-chat capture options for this session — chat_deleted supersedes.
4598 delete_option('mxchat_email_' . $session_id_sanitized);
4599 delete_option('mxchat_name_' . $session_id_sanitized);
4600 $preserved_as_deleted_leads++;
4601 }
4602 } else {
4603 // No lead attached — nothing to preserve. Clean up any orphan options anyway.
4604 delete_option('mxchat_email_' . $session_id_sanitized);
4605 delete_option('mxchat_name_' . $session_id_sanitized);
4606 }
4607
4608 $deleted_count++;
4609 }
4610
4611 // Opt-in full-lead wipe: sweep every remaining row + every option key (including
4612 // chat_deleted preservation) for each affected email. Reuses the same internal
4613 // helper as the Leads-tab Delete button for consistency.
4614 if (!empty($emails_to_fully_wipe)) {
4615 self::mxchat_wipe_leads_by_email(array_values($emails_to_fully_wipe));
4616 }
4617
4618 wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions');
4619
4620 echo wp_json_encode([
4621 'success' => sprintf(
4622 esc_html__('%d chat session(s) have been deleted.', 'mxchat'),
4623 $deleted_count
4624 ),
4625 'preserved_as_deleted_leads' => $preserved_as_deleted_leads,
4626 'leads_fully_wiped' => count($emails_to_fully_wipe),
4627 ]);
4628 wp_die();
4629 }
4630
4631 /**
4632 * Format transcript message content with markdown processing
4633 * Converts markdown links, bold, italic, code blocks, and plain URLs to HTML
4634 */
4635 private function format_transcript_message($text) {
4636 if (empty($text)) {
4637 return '';
4638 }
4639
4640 // Normalize line endings and clean up excessive whitespace
4641 $text = str_replace("\r\n", "\n", $text);
4642 $text = str_replace("\r", "\n", $text);
4643
4644 // Clean up existing <br> tags that may have been saved (legacy data)
4645 // Convert <br>, <br/>, <br /> back to newlines for consistent processing
4646 $text = preg_replace('/<br\s*\/?>\s*/i', "\n", $text);
4647
4648 // Collapse 3+ consecutive newlines to just 2 (paragraph break)
4649 $text = preg_replace('/\n{3,}/', "\n\n", $text);
4650
4651 // Process markdown headers (# Header)
4652 $text = preg_replace_callback('/^(#{1,6})\s+(.+)$/m', function($matches) {
4653 $level = strlen($matches[1]);
4654 $content = esc_html(trim($matches[2]));
4655 return "<h{$level}>{$content}</h{$level}>";
4656 }, $text);
4657
4658 // Process code blocks with triple backticks
4659 $text = preg_replace_callback('/```(\w+)?\n?([\s\S]*?)```/', function($matches) {
4660 $language = !empty($matches[1]) ? ' class="language-' . esc_attr($matches[1]) . '"' : '';
4661 $code = esc_html($matches[2]);
4662 return "<pre><code{$language}>{$code}</code></pre>";
4663 }, $text);
4664
4665 // Process inline code with single backticks
4666 $text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text);
4667
4668 // Process bold text **text** or __text__
4669 $text = preg_replace('/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text);
4670 $text = preg_replace('/__(.+?)__/', '<strong>$1</strong>', $text);
4671
4672 // Process italic text *text* or _text_ (but not if part of URL)
4673 $text = preg_replace('/(?<![*_\w])\*([^*]+)\*(?![*\w])/', '<em>$1</em>', $text);
4674 $text = preg_replace('/(?<![*_\w])_([^_]+)_(?![*\w])/', '<em>$1</em>', $text);
4675
4676 // Process markdown links [text](url)
4677 $text = preg_replace_callback('/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', function($matches) {
4678 $link_text = esc_html($matches[1]);
4679 $url = esc_url($matches[2]);
4680 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$link_text}</a>";
4681 }, $text);
4682
4683 // Process citation-style brackets [URL]
4684 $text = preg_replace_callback('/\[(https?:\/\/[^\]]+)\]/', function($matches) {
4685 $url = esc_url($matches[1]);
4686 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$url}</a>";
4687 }, $text);
4688
4689 // Process standalone URLs (not already in links or img src)
4690 $text = preg_replace_callback(
4691 '/(?<!href="|src="|">)(https?:\/\/[^\s<>"]+)(?![^<]*<\/a>)/',
4692 function($matches) {
4693 $url = esc_url($matches[1]);
4694 // Truncate display URL if too long
4695 $display = strlen($matches[1]) > 50 ? substr($matches[1], 0, 47) . '...' : $matches[1];
4696 return "<a href=\"{$url}\" target=\"_blank\" rel=\"noopener\">{$display}</a>";
4697 },
4698 $text
4699 );
4700
4701 // Process mailto links
4702 $text = preg_replace_callback('/\[([^\]]+)\]\((mailto:[^)]+)\)/', function($matches) {
4703 $link_text = esc_html($matches[1]);
4704 $mailto = esc_url($matches[2]);
4705 return "<a href=\"{$mailto}\">{$link_text}</a>";
4706 }, $text);
4707
4708 // Convert paragraphs: split by double newlines, wrap in <p> tags
4709 // This creates proper paragraph structure instead of excessive <br> tags
4710 $paragraphs = preg_split('/\n\n+/', $text);
4711
4712 // Filter out empty paragraphs but preserve content like "0"
4713 $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), function($p) {
4714 return $p !== '';
4715 }));
4716
4717 if (empty($paragraphs)) {
4718 // No content after filtering
4719 return '';
4720 } elseif (count($paragraphs) > 1) {
4721 // Multiple paragraphs - wrap each in <p> tags, convert single newlines to <br>
4722 $formatted_paragraphs = array_map(function($p) {
4723 return nl2br($p);
4724 }, $paragraphs);
4725 $text = '<p>' . implode('</p><p>', $formatted_paragraphs) . '</p>';
4726 } else {
4727 // Single paragraph - just convert newlines to <br>
4728 $text = nl2br($paragraphs[0]);
4729 }
4730
4731 return $text;
4732 }
4733
4734 /**
4735 * AJAX handler to fetch RAG context for a specific message
4736 * Used by the transcript viewer to show retrieved documents
4737 */
4738 public function mxchat_get_rag_context() {
4739 // Check permissions
4740 if (!current_user_can('manage_options')) {
4741 wp_send_json_error(['message' => esc_html__('You do not have sufficient permissions.', 'mxchat')]);
4742 wp_die();
4743 }
4744
4745 // Validate message ID
4746 if (!isset($_POST['message_id']) || empty($_POST['message_id'])) {
4747 wp_send_json_error(['message' => esc_html__('Message ID is required.', 'mxchat')]);
4748 wp_die();
4749 }
4750
4751 $message_id = absint($_POST['message_id']);
4752
4753 global $wpdb;
4754 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
4755
4756 // Fetch the RAG context for this message
4757 $result = $wpdb->get_row($wpdb->prepare(
4758 "SELECT rag_context FROM {$table_name} WHERE id = %d",
4759 $message_id
4760 ));
4761
4762 if (!$result || empty($result->rag_context)) {
4763 wp_send_json_error(['message' => esc_html__('No RAG context found for this message.', 'mxchat')]);
4764 wp_die();
4765 }
4766
4767 // Decode the JSON data
4768 $rag_context = json_decode($result->rag_context, true);
4769
4770 if (json_last_error() !== JSON_ERROR_NONE) {
4771 wp_send_json_error(['message' => esc_html__('Invalid RAG context data.', 'mxchat')]);
4772 wp_die();
4773 }
4774
4775 wp_send_json_success($rag_context);
4776 wp_die();
4777 }
4778
4779 public function display_admin_notices() {
4780 // Check if we're on a MXChat admin page
4781 $screen = get_current_screen();
4782 if (!$screen || strpos($screen->base, 'mxchat') === false) {
4783 return;
4784 }
4785
4786 $dismiss_button = '<button type="button" class="notice-dismiss"><span class="screen-reader-text">' . esc_html__('Dismiss this notice.', 'mxchat') . '</span></button>';
4787
4788 // Check for error notices
4789 $error_notice = get_transient('mxchat_admin_notice_error');
4790 if ($error_notice) {
4791 echo '<div class="notice notice-error is-dismissible"><p>' . wp_kses_post($error_notice) . '</p>' . $dismiss_button . '</div>';
4792 delete_transient('mxchat_admin_notice_error');
4793 }
4794
4795 // Check for success notices
4796 $success_notice = get_transient('mxchat_admin_notice_success');
4797 if ($success_notice) {
4798 echo '<div class="notice notice-success is-dismissible"><p>' . wp_kses_post($success_notice) . '</p>' . $dismiss_button . '</div>';
4799 delete_transient('mxchat_admin_notice_success');
4800 }
4801
4802 // Check for info notices
4803 $info_notice = get_transient('mxchat_admin_notice_info');
4804 if ($info_notice) {
4805 echo '<div class="notice notice-info is-dismissible"><p>' . wp_kses_post($info_notice) . '</p>' . $dismiss_button . '</div>';
4806 delete_transient('mxchat_admin_notice_info');
4807 }
4808 }
4809
4810
4811 public function mxchat_create_activation_page() {
4812 // Include the new Pro & Extensions page template
4813 require_once plugin_dir_path(__FILE__) . 'admin-pro-page.php';
4814
4815 // Get addons configuration from the MxChat_Addons class
4816 require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php';
4817 $addons_instance = new MxChat_Addons();
4818 $addons_config = $addons_instance->get_addons_config();
4819
4820 // Render the consolidated Pro & Extensions page
4821 mxchat_render_pro_page($this, $addons_config);
4822 }
4823
4824 /**
4825 * Check if current activation is linked to a domain
4826 * This checks YOUR website's database, not the user's local database
4827 */
4828 public function is_current_activation_linked($domain) {
4829 $license_key = get_option('mxchat_activation_key');
4830 $email = get_option('mxchat_pro_email');
4831
4832 if (empty($license_key) || empty($email)) {
4833 return false;
4834 }
4835
4836 // Check with YOUR website's API
4837 $response = wp_remote_post('https://mxchat.ai/mxchat-api/check-domain', array(
4838 'body' => array(
4839 'license_key' => $license_key,
4840 'email' => $email,
4841 'domain' => $domain
4842 ),
4843 'timeout' => 10,
4844 'sslverify' => false
4845 ));
4846
4847 if (is_wp_error($response)) {
4848 return false;
4849 }
4850
4851 $body = json_decode(wp_remote_retrieve_body($response), true);
4852 return isset($body['success']) && $body['success'] && isset($body['data']['linked']) && $body['data']['linked'];
4853 }
4854
4855
4856 public function mxchat_actions_page_html() {
4857 if (!current_user_can('manage_options')) {
4858 return;
4859 }
4860
4861 global $wpdb;
4862 $table_name = $wpdb->prefix . 'mxchat_intents';
4863
4864 // Get stats for dashboard
4865 $total_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
4866 $enabled_actions = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE enabled = 1");
4867 $disabled_actions = $total_actions - $enabled_actions;
4868
4869 // Get unique action types count
4870 $action_types_count = $wpdb->get_var("SELECT COUNT(DISTINCT callback_function) FROM $table_name");
4871
4872 // Get action type distribution
4873 $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");
4874 $available_callbacks = $this->mxchat_get_available_callbacks();
4875 $callback_groups = $this->mxchat_get_available_callbacks(true, true);
4876
4877 $action_type_distribution = array();
4878 foreach ($type_distribution_raw as $row) {
4879 $label = isset($available_callbacks[$row->callback_function]['label'])
4880 ? $available_callbacks[$row->callback_function]['label']
4881 : $row->callback_function;
4882 $action_type_distribution[$label] = $row->count;
4883 }
4884
4885 // Native function-calling (AI Tools) data — plan-mxchat-20260617-a41dee.
4886 // The AI Tools checklist reads from MxChat_Tool_Registry, the SAME single
4887 // source the chat-time function-calling loop reads, so the two never drift.
4888 if (!class_exists('MxChat_Tool_Registry')) {
4889 require_once plugin_dir_path(__FILE__) . 'class-mxchat-tool-registry.php';
4890 }
4891 if (!class_exists('MxChat_Model_Catalog')) {
4892 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
4893 }
4894 $fc_options = get_option('mxchat_options', array());
4895 $fc_current_model = isset($fc_options['model']) ? $fc_options['model'] : 'gpt-5.6-sol';
4896 $fc_model_capable = class_exists('MxChat_Model_Catalog')
4897 ? MxChat_Model_Catalog::supports_tools($fc_current_model) : true;
4898 $active_tab = (isset($_GET['tab']) && $_GET['tab'] === 'ai-tools') ? 'ai-tools' : 'dashboard';
4899
4900 // Enrich each AI Tool with the dashicon its callback already uses on the
4901 // Trigger Phrases "Choose what it does" grid, so the AI Tools cards/modal
4902 // share the same iconography (plan 8bbf98 part 4). View-layer only — the
4903 // registry's model-facing data is untouched. Default admin-generic.
4904 $fc_tools = MxChat_Tool_Registry::available_tools();
4905 foreach ($fc_tools as &$fc_tool_ref) {
4906 $fc_cb_ref = $fc_tool_ref['callback'];
4907 $fc_tool_ref['icon'] = isset($available_callbacks[$fc_cb_ref]['icon'])
4908 ? $available_callbacks[$fc_cb_ref]['icon']
4909 : 'admin-generic';
4910 }
4911 unset($fc_tool_ref);
4912
4913 // Count of ACTIVE tools — drives the AI Tools sidebar nav badge, mirroring
4914 // $total_actions for Trigger Phrases. A tool in the list = active (plan
4915 // d450a7), so the badge shows the same number the list pane shows (plan 5f7409).
4916 $total_tools = count(array_filter($fc_tools, function ($t) {
4917 return !empty($t['enabled']);
4918 }));
4919
4920 // Brave-key dependency check (plan 183856): Web Search + Image Search run on
4921 // the Brave Search API. If either is enabled as a tool but no brave_api_key is
4922 // configured, surface a graceful "key not set" notice so the admin isn't met
4923 // with a silently no-firing tool.
4924 $fc_brave_missing = false;
4925 $brave_key = isset($fc_options['brave_api_key']) ? trim($fc_options['brave_api_key']) : '';
4926 if ($brave_key === '') {
4927 foreach ($fc_tools as $fc_t) {
4928 if (!empty($fc_t['enabled']) && isset($fc_t['requires_key']) && $fc_t['requires_key'] === 'brave_api_key') {
4929 $fc_brave_missing = true;
4930 break;
4931 }
4932 }
4933 }
4934
4935 // Prepare page data
4936 $page_data = array(
4937 'total_actions' => $total_actions,
4938 'enabled_actions' => $enabled_actions,
4939 'disabled_actions' => $disabled_actions,
4940 'action_types_count' => $action_types_count,
4941 'action_type_distribution' => $action_type_distribution,
4942 'available_callbacks' => $available_callbacks,
4943 'callback_groups' => $callback_groups,
4944 // AI Tools section
4945 'active_tab' => $active_tab,
4946 'fc_enabled' => MxChat_Tool_Registry::is_enabled(),
4947 'total_tools' => $total_tools,
4948 'fc_tools' => $fc_tools,
4949 'fc_current_model' => $fc_current_model,
4950 'fc_model_capable' => $fc_model_capable,
4951 'fc_brave_missing' => $fc_brave_missing,
4952 'fc_saved' => isset($_GET['mxchat_fc_saved']),
4953 );
4954
4955 // Include and render the new template
4956 require_once plugin_dir_path(__FILE__) . 'admin-actions-page.php';
4957 mxchat_render_actions_page($this, $page_data);
4958 }
4959
4960 /**
4961 * LEGACY HTML - Preserved below for reference, to be removed in future update
4962 */
4963 function mxchat_actions_page_legacy_html() {
4964 // This function is deprecated and no longer used
4965 // The new template is in includes/admin-actions-page.php
4966 ?>
4967 <div class="wrap mxchat-wrapper">
4968 <!-- Hero Section -->
4969 <div class="mxchat-hero">
4970 <h1 class="mxchat-main-title">
4971 <span class="mxchat-gradient-text">Actions</span> Manager
4972 </h1>
4973 <p class="mxchat-hero-subtitle">
4974 <?php esc_html_e('Create and manage custom actions to enhance your chatbot\'s capabilities.', 'mxchat'); ?>
4975 </p>
4976 </div>
4977
4978 <!-- Actions Header with Search and Filter -->
4979 <div class="mxchat-actions-header">
4980 <div class="mxchat-actions-filters">
4981 <form method="get" class="mxchat-search-form">
4982 <input type="hidden" name="page" value="mxchat-actions">
4983 <div class="mxchat-search-group">
4984 <span class="dashicons dashicons-search"></span>
4985 <input type="text" name="s" class="mxchat-search-input"
4986 placeholder="<?php esc_attr_e('Search Actions', 'mxchat'); ?>"
4987 value="<?php echo esc_attr($search_term); ?>">
4988 </div>
4989 <select name="callback_filter" class="mxchat-action-filter">
4990 <option value=""><?php esc_html_e('All Action Types', 'mxchat'); ?></option>
4991 <?php foreach ($available_callbacks as $function => $callback_data) :
4992 $label = $callback_data['label']; ?>
4993 <option value="<?php echo esc_attr($function); ?>"
4994 <?php selected($callback_filter, $function); ?>>
4995 <?php echo esc_html($label); ?>
4996 </option>
4997 <?php endforeach; ?>
4998 </select>
4999 <button type="submit" class="mxchat-button-secondary">
5000 <?php esc_html_e('Filter', 'mxchat'); ?>
5001 </button>
5002 </form>
5003 </div>
5004 <div class="mxchat-actions-controls">
5005 <button type="button" id="mxchat-add-action-btn" class="mxchat-button-primary">
5006 <span class="dashicons dashicons-plus-alt"></span>
5007 <?php esc_html_e('Add New Action', 'mxchat'); ?>
5008 </button>
5009 </div>
5010 </div>
5011
5012 <!-- Actions Grid Layout - All actions in a single grid -->
5013 <div class="mxchat-actions-grid">
5014 <div class="mxchat-cards-container">
5015 <?php if (!empty($actions)) : ?>
5016 <?php foreach ($actions as $action) :
5017 $callback_function = $action->callback_function;
5018 $callback_label = isset($available_callbacks[$callback_function]['label'])
5019 ? $available_callbacks[$callback_function]['label']
5020 : $callback_function;
5021 $threshold_value = isset($action->similarity_threshold)
5022 ? round($action->similarity_threshold * 100)
5023 : 85;
5024
5025 // Check if this is a form action
5026 $is_form_action = strpos($action->intent_label, 'Form ') === 0;
5027
5028 // Get action status (enabled/disabled) - default to true if column doesn't exist
5029 $is_enabled = isset($action->enabled) ? (bool)$action->enabled : true;
5030
5031 // Get enabled bots for display
5032 $enabled_bots = [];
5033 if (isset($action->enabled_bots) && !empty($action->enabled_bots)) {
5034 $enabled_bots = json_decode($action->enabled_bots, true);
5035 if (!is_array($enabled_bots)) {
5036 $enabled_bots = ['default'];
5037 }
5038 } else {
5039 $enabled_bots = ['default']; // Backward compatibility
5040 }
5041 ?>
5042 <div class="mxchat-action-card <?php echo $is_form_action ? 'mxchat-form-action' : ''; ?>">
5043 <div class="mxchat-card-header">
5044 <div class="mxchat-card-title"><?php echo esc_html($action->intent_label); ?></div>
5045 <div class="mxchat-card-toggle">
5046 <label class="mxchat-switch">
5047 <input type="checkbox" class="mxchat-action-toggle"
5048 data-action-id="<?php echo esc_attr($action->id); ?>"
5049 <?php checked($is_enabled); ?>>
5050 <span class="mxchat-slider round"></span>
5051 </label>
5052 </div>
5053 </div>
5054
5055 <div class="mxchat-card-body">
5056 <div class="mxchat-card-description">
5057 <strong><?php esc_html_e('Type:', 'mxchat'); ?></strong>
5058 <?php echo esc_html($callback_label); ?>
5059 </div>
5060
5061 <div class="mxchat-card-phrases">
5062 <strong><?php esc_html_e('Trigger phrases:', 'mxchat'); ?></strong>
5063 <div class="mxchat-phrases-preview">
5064 <?php
5065 // Check if the helper function exists, otherwise use a simple substring
5066 if (method_exists($this, 'get_trimmed_phrases')) {
5067 echo esc_html($this->get_trimmed_phrases($action->phrases));
5068 } else {
5069 echo esc_html(strlen($action->phrases) > 100 ?
5070 substr($action->phrases, 0, 97) . '...' :
5071 $action->phrases);
5072 }
5073 ?>
5074 </div>
5075 </div>
5076
5077 <div class="mxchat-threshold-control">
5078 <div class="mxchat-threshold-label">
5079 <?php esc_html_e('Similarity Threshold:', 'mxchat'); ?>
5080 <span class="mxchat-threshold-value"><?php echo esc_html($threshold_value); ?>%</span>
5081 </div>
5082 </div>
5083
5084 <!-- Bot Availability Display (Read-only) -->
5085 <div class="mxchat-card-bots">
5086 <strong><?php esc_html_e('Assigned bots:', 'mxchat'); ?></strong>
5087 <div class="mxchat-bot-badges">
5088 <?php
5089 foreach ($enabled_bots as $bot_id) {
5090 if ($bot_id === 'default') {
5091 echo '<span class="mxchat-bot-badge">' . esc_html__('Default Bot', 'mxchat') . '</span>';
5092 } else {
5093 // Try to get bot name from multi-bot manager
5094 if (class_exists('MxChat_Multi_Bot_Core_Manager')) {
5095 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
5096 $available_bots = $multi_bot_manager->get_available_bots();
5097 $bot_name = isset($available_bots[$bot_id]) ? $available_bots[$bot_id] : $bot_id;
5098 echo '<span class="mxchat-bot-badge">' . esc_html($bot_name) . '</span>';
5099 } else {
5100 echo '<span class="mxchat-bot-badge">' . esc_html($bot_id) . '</span>';
5101 }
5102 }
5103 }
5104 ?>
5105 </div>
5106 </div>
5107 </div>
5108
5109 <div class="mxchat-card-footer">
5110 <?php
5111 // Check if it's a form action
5112 $is_form_action = preg_match('/Form (\d+)/', $action->intent_label, $form_matches);
5113
5114 // Check if it's a recommendation flow action
5115 $is_flow_action = preg_match('/Recommendation Flow (\d+)/', $action->intent_label, $flow_matches);
5116
5117 if ($is_form_action) {
5118 $form_id = isset($form_matches[1]) ? $form_matches[1] : '';
5119 ?>
5120 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-forms&action=edit&form_id=' . $form_id)); ?>"
5121 class="mxchat-button-primary">
5122 <span class="dashicons dashicons-feedback"></span>
5123 <?php esc_html_e('Edit Form', 'mxchat'); ?>
5124 </a>
5125 <?php } elseif ($is_flow_action) {
5126 ?>
5127 <a href="<?php echo esc_url(admin_url('admin.php?page=mxchat-smart-recommender')); ?>"
5128 class="mxchat-button-primary">
5129 <span class="dashicons dashicons-list-view"></span>
5130 <?php esc_html_e('Manage Flows', 'mxchat'); ?>
5131 </a>
5132 <?php } else { ?>
5133 <button type="button"
5134 class="mxchat-button-secondary mxchat-edit-button"
5135 data-action-id="<?php echo esc_attr($action->id); ?>"
5136 data-phrases="<?php echo esc_attr($action->phrases); ?>"
5137 data-label="<?php echo esc_attr($action->intent_label); ?>"
5138 data-threshold="<?php echo esc_attr(round($action->similarity_threshold * 100)); ?>"
5139 data-callback-function="<?php echo esc_attr($action->callback_function); ?>"
5140 data-enabled-bots="<?php echo esc_attr(json_encode($enabled_bots)); ?>">
5141 <span class="dashicons dashicons-edit"></span>
5142 <?php esc_html_e('Edit', 'mxchat'); ?>
5143 </button>
5144 <form method="post"
5145 action="<?php echo esc_url(admin_url('admin-post.php')); ?>"
5146 class="mxchat-delete-form"
5147 onsubmit="return confirm('<?php esc_attr_e('Are you sure you want to delete this action?', 'mxchat'); ?>');">
5148 <?php wp_nonce_field('mxchat_delete_intent_nonce'); ?>
5149 <input type="hidden" name="action" value="mxchat_delete_intent">
5150 <input type="hidden" name="intent_id" value="<?php echo esc_attr($action->id); ?>">
5151 <button type="submit" class="mxchat-button-text mxchat-delete-button">
5152 <span class="dashicons dashicons-trash"></span>
5153 <?php esc_html_e('Delete', 'mxchat'); ?>
5154 </button>
5155 </form>
5156 <?php } ?>
5157 </div>
5158 </div>
5159 <?php endforeach; ?>
5160 <?php else : ?>
5161 <!-- If no actions found -->
5162 <div class="mxchat-no-actions">
5163 <div class="mxchat-empty-state">
5164 <span class="dashicons dashicons-format-chat"></span>
5165 <h2><?php esc_html_e('No actions found', 'mxchat'); ?></h2>
5166 <p><?php esc_html_e('Get started by creating your first action to enhance your chatbot.', 'mxchat'); ?></p>
5167 <button type="button" id="mxchat-create-first-action" class="mxchat-button-primary">
5168 <?php esc_html_e('Create Your First Action', 'mxchat'); ?>
5169 </button>
5170 </div>
5171 </div>
5172 <?php endif; ?>
5173 </div>
5174 </div>
5175
5176 <?php if ($total_pages > 1) : ?>
5177 <div class="mxchat-pagination">
5178 <?php
5179 echo paginate_links(array(
5180 'base' => add_query_arg('paged', '%#%'),
5181 'format' => '',
5182 'prev_text' => __('&laquo; Previous', 'mxchat'),
5183 'next_text' => __('Next &raquo;', 'mxchat'),
5184 'total' => $total_pages,
5185 'current' => $page
5186 ));
5187 ?>
5188 </div>
5189 <?php endif; ?>
5190
5191 <!-- Add/Edit Action Modal with Step-Based Approach -->
5192 <!-- Complete Modal HTML with Defined Groups Variable -->
5193 <div id="mxchat-action-modal" class="mxchat-modal" style="display: none;">
5194 <div class="mxchat-modal-content">
5195 <span class="mxchat-modal-close">&times;</span>
5196
5197 <form id="mxchat-action-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
5198 <!-- Dynamic nonce field -->
5199 <div id="action-nonce-container">
5200 <?php wp_nonce_field('mxchat_add_intent_nonce', 'add_intent_nonce'); ?>
5201 </div>
5202 <input type="hidden" name="action" id="form_action_type" value="mxchat_add_intent">
5203 <input type="hidden" name="intent_id" id="edit_action_id" value="">
5204 <input type="hidden" name="callback_function" id="callback_function" value="">
5205
5206 <!-- Step 1: Action Type Selection -->
5207 <div id="mxchat-action-step-1" class="mxchat-action-step active">
5208 <div class="mxchat-step-indicator">
5209 <div class="mxchat-step-number">1</div>
5210 <div class="mxchat-step-title"><?php esc_html_e('Select Action Type', 'mxchat'); ?></div>
5211 </div>
5212
5213 <div id="mxchat-action-type-selector" class="mxchat-action-type-selector">
5214 <div class="mxchat-action-type-search">
5215 <span class="dashicons dashicons-search"></span>
5216 <input type="text" id="action-type-search" placeholder="<?php esc_attr_e('Search action types...', 'mxchat'); ?>" class="mxchat-action-type-search-input">
5217 </div>
5218
5219 <?php
5220 // Get the callbacks - IMPORTANT: Define the $groups variable here
5221 $groups = $this->mxchat_get_available_callbacks(true, true);
5222 ?>
5223
5224 <div class="mxchat-action-type-categories">
5225 <button type="button" class="mxchat-category-button active" data-category="all"><?php esc_html_e('All', 'mxchat'); ?></button>
5226 <?php
5227 // Get unique categories from the defined groups
5228 foreach ($groups as $group_label => $group_callbacks) :
5229 $category_slug = sanitize_title($group_label);
5230 ?>
5231 <button type="button" class="mxchat-category-button" data-category="<?php echo esc_attr($category_slug); ?>"><?php echo esc_html($group_label); ?></button>
5232 <?php endforeach; ?>
5233 </div>
5234
5235 <div class="mxchat-action-types-grid">
5236 <?php
5237 // Generate action cards from available callbacks
5238 foreach ($groups as $group_label => $group_callbacks) :
5239 $category_slug = sanitize_title($group_label);
5240
5241 foreach ($group_callbacks as $function => $data) :
5242 $label = $data['label'];
5243 $icon = isset($data['icon']) ? $data['icon'] : 'admin-generic';
5244 $description = isset($data['description']) ? $data['description'] : '';
5245 $is_addon = isset($data['addon']) && $data['addon'] !== false;
5246 $addon_name = isset($data['addon_name']) ? $data['addon_name'] : '';
5247 $is_installed = isset($data['installed']) ? $data['installed'] : true;
5248 $is_promo = !empty($data['addon_promo']) && !$is_installed;
5249
5250 // Determine card status and styling
5251 $card_class = 'mxchat-action-type-card';
5252 $icon_class = 'mxchat-action-type-icon';
5253 $status_badge = '';
5254
5255 if ($is_promo) {
5256 // Promotional card — not selectable, just informational
5257 $card_class .= ' not-installed mxchat-promo-card';
5258 $status_badge = '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
5259 } elseif ($is_addon && !$is_installed) {
5260 // Add-on not installed
5261 $card_class .= ' not-installed';
5262 $status_badge .= '<span class="mxchat-addon-badge">' . esc_html__('Add-on Required', 'mxchat') . '</span>';
5263 }
5264
5265 // Default description if none provided
5266 if (empty($description)) {
5267 $description = sprintf(
5268 esc_html__('Use the %s action in your chatbot', 'mxchat'),
5269 $label
5270 );
5271 }
5272 ?>
5273 <div class="<?php echo esc_attr($card_class); ?>"
5274 data-category="<?php echo esc_attr($category_slug); ?>"
5275 <?php if (!$is_promo) : ?>
5276 data-value="<?php echo esc_attr($function); ?>"
5277 data-label="<?php echo esc_attr($label); ?>"
5278 <?php endif; ?>
5279 data-pro="false"
5280 data-addon="<?php echo esc_attr($is_addon ? $data['addon'] : ''); ?>"
5281 data-installed="<?php echo $is_installed ? 'true' : 'false'; ?>"
5282 <?php if ($is_promo) : ?>data-promo="true"<?php endif; ?>>
5283 <div class="<?php echo esc_attr($icon_class); ?>">
5284 <span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span>
5285 </div>
5286 <div class="mxchat-action-type-info">
5287 <h4><?php echo esc_html($label); ?></h4>
5288 <p><?php echo esc_html($description); ?></p>
5289 <?php if (!empty($status_badge)) : ?>
5290 <?php echo $status_badge; ?>
5291 <?php endif; ?>
5292
5293 <?php if ($is_promo || ($is_addon && !$is_installed)) : ?>
5294 <div class="mxchat-addon-info">
5295 <?php echo esc_html(sprintf(
5296 __('Requires %s', 'mxchat'),
5297 $addon_name
5298 )); ?>
5299 — <a href="https://mxchat.ai/" target="_blank"><?php esc_html_e('Get Add-on', 'mxchat'); ?></a>
5300 </div>
5301 <?php endif; ?>
5302 </div>
5303 </div>
5304 <?php endforeach;
5305 endforeach; ?>
5306 </div>
5307 </div>
5308
5309 <div class="mxchat-modal-actions">
5310 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
5311 <?php esc_html_e('Cancel', 'mxchat'); ?>
5312 </button>
5313 </div>
5314 </div>
5315
5316 <!-- Step 2: Action Configuration -->
5317 <div id="mxchat-action-step-2" class="mxchat-action-step">
5318 <div class="mxchat-step-indicator">
5319 <div class="mxchat-step-number">2</div>
5320 <div class="mxchat-step-title"><?php esc_html_e('Configure Action', 'mxchat'); ?></div>
5321 </div>
5322
5323 <div class="mxchat-selected-action">
5324 <button type="button" class="mxchat-back-button" id="mxchat-back-to-step-1">
5325 <span class="dashicons dashicons-arrow-left-alt"></span>
5326 <?php esc_html_e('Back to Action Types', 'mxchat'); ?>
5327 </button>
5328 <div class="mxchat-selected-action-info">
5329 <div id="selected-action-icon" class="mxchat-action-type-icon">
5330 <span class="dashicons dashicons-admin-generic"></span>
5331 </div>
5332 <div class="mxchat-selected-action-details">
5333 <h3 id="selected-action-title"><?php esc_html_e('Selected Action', 'mxchat'); ?></h3>
5334 <p id="selected-action-description"><?php esc_html_e('Configure this action for your chatbot', 'mxchat'); ?></p>
5335 </div>
5336 </div>
5337 </div>
5338
5339 <div class="mxchat-form-group">
5340 <label for="intent_label">
5341 <?php esc_html_e('Action Label (For your reference only)', 'mxchat'); ?>
5342 </label>
5343 <input name="intent_label" type="text" id="intent_label" required
5344 class="mxchat-intent-input"
5345 placeholder="<?php esc_attr_e('Example: Newsletter Signup', 'mxchat'); ?>">
5346 </div>
5347
5348 <div class="mxchat-form-group">
5349 <label for="phrases">
5350 <?php esc_html_e('Trigger Phrases (comma-separated)', 'mxchat'); ?>
5351 </label>
5352 <textarea name="phrases" id="action_phrases" rows="5" required
5353 class="mxchat-intent-textarea"
5354 placeholder="<?php esc_attr_e('Example: sign me up, subscribe me, I want to join, add me to the newsletter', 'mxchat'); ?>"></textarea>
5355 </div>
5356
5357 <div class="mxchat-form-group">
5358 <label for="similarity_threshold">
5359 <?php esc_html_e('Similarity Threshold', 'mxchat'); ?>
5360 <span class="mxchat-threshold-value-display">85%</span>
5361 </label>
5362 <div class="mxchat-slider-group modal-slider">
5363 <input type="range"
5364 name="similarity_threshold"
5365 id="similarity_threshold"
5366 min="10"
5367 max="95"
5368 value="85"
5369 class="mxchat-intent-slider"
5370 oninput="document.querySelector('.mxchat-threshold-value-display').textContent = this.value + '%'">
5371 </div>
5372 <div class="mxchat-threshold-hint">
5373 <?php esc_html_e('Lower values (10-30) make the action trigger more easily. Higher values (70-95) require more exact matches.', 'mxchat'); ?>
5374 </div>
5375 </div>
5376
5377 <!-- Bot Selection Section -->
5378 <div class="mxchat-form-group">
5379 <label for="enabled_bots">
5380 <?php esc_html_e('Which bots should we enable this action for?', 'mxchat'); ?>
5381 </label>
5382 <div class="mxchat-bot-selector">
5383 <div class="mxchat-bot-option">
5384 <label class="mxchat-checkbox-label">
5385 <input type="checkbox"
5386 name="enabled_bots[]"
5387 value="default"
5388 id="bot_default"
5389 checked="checked">
5390 <span class="mxchat-checkmark"></span>
5391 <?php esc_html_e('Default Bot', 'mxchat'); ?>
5392 </label>
5393 </div>
5394
5395 <?php if (class_exists('MxChat_Multi_Bot_Core_Manager')) :
5396 $multi_bot_manager = MxChat_Multi_Bot_Core_Manager::get_instance();
5397 $available_bots = $multi_bot_manager->get_available_bots();
5398
5399 foreach ($available_bots as $bot_id => $bot_name) :
5400 if ($bot_id === 'default') continue; // Skip default, already shown above
5401 ?>
5402 <div class="mxchat-bot-option">
5403 <label class="mxchat-checkbox-label">
5404 <input type="checkbox"
5405 name="enabled_bots[]"
5406 value="<?php echo esc_attr($bot_id); ?>"
5407 id="bot_<?php echo esc_attr($bot_id); ?>">
5408 <span class="mxchat-checkmark"></span>
5409 <?php echo esc_html($bot_name); ?>
5410 </label>
5411 </div>
5412 <?php
5413 endforeach;
5414 endif; ?>
5415 </div>
5416 </div>
5417
5418 <div class="mxchat-modal-actions">
5419 <button type="button" class="mxchat-button-secondary mxchat-modal-cancel">
5420 <?php esc_html_e('Cancel', 'mxchat'); ?>
5421 </button>
5422 <button type="submit" class="mxchat-button-primary" id="mxchat-save-action-btn">
5423 <?php esc_html_e('Save Action', 'mxchat'); ?>
5424 </button>
5425 </div>
5426 </div>
5427 </form>
5428 </div>
5429 </div>
5430
5431 <div id="mxchat-action-loading" class="mxchat-action-loading" style="display: none;">
5432 <div class="mxchat-action-loading-spinner"></div>
5433 <div class="mxchat-action-loading-text">
5434 <?php esc_html_e('Saving action, please wait...', 'mxchat'); ?>
5435 </div>
5436 </div>
5437 </div><!-- .mxchat-wrapper -->
5438 <?php
5439 }
5440 private function get_trimmed_phrases($phrases, $max_length = 100) {
5441 if (strlen($phrases) <= $max_length) {
5442 return $phrases;
5443 }
5444
5445 $trimmed = substr($phrases, 0, $max_length);
5446 $last_comma = strrpos($trimmed, ',');
5447
5448 if ($last_comma !== false) {
5449 $trimmed = substr($trimmed, 0, $last_comma);
5450 }
5451
5452 return $trimmed . '...';
5453 }
5454 public function mxchat_add_enabled_column_to_intents() {
5455 global $wpdb;
5456 $table_name = $wpdb->prefix . 'mxchat_intents';
5457
5458 // Check if the column already exists
5459 $columns = $wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'enabled'");
5460
5461 if (empty($columns)) {
5462 // Add the column with default value of 1 (enabled)
5463 $wpdb->query("ALTER TABLE $table_name ADD COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1");
5464 }
5465 }
5466
5467 public function mxchat_handle_delete_intent() {
5468 if ( ! current_user_can( 'manage_options' ) ) {
5469 wp_die( esc_html__('Unauthorized user', 'mxchat') );
5470 }
5471
5472 check_admin_referer('mxchat_delete_intent_nonce');
5473
5474 if (isset($_POST['intent_id'])) {
5475 global $wpdb;
5476 $table_name = $wpdb->prefix . 'mxchat_intents';
5477 $intent_id = intval($_POST['intent_id']);
5478
5479 $wpdb->delete($table_name, ['id' => $intent_id], ['%d']);
5480 }
5481
5482 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5483 exit;
5484 }
5485 public function mxchat_handle_edit_intent() {
5486 // Security checks (nonce and permissions)
5487 if (!current_user_can('manage_options')) {
5488 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5489 }
5490 check_admin_referer('mxchat_edit_intent');
5491
5492 // Get POST data
5493 $intent_id = isset($_POST['intent_id']) ? absint($_POST['intent_id']) : 0;
5494 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5495 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5496 $threshold_percentage = isset($_POST['similarity_threshold']) ? intval($_POST['similarity_threshold']) : 85;
5497 $similarity_threshold = min(95, max(10, $threshold_percentage)) / 100; // Convert to 0.10–0.95
5498
5499 // Handle enabled_bots
5500 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5501 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5502
5503 // Ensure default is always included for backward compatibility
5504 if (!in_array('default', $enabled_bots)) {
5505 $enabled_bots[] = 'default';
5506 }
5507
5508 $enabled_bots_json = json_encode($enabled_bots);
5509
5510 // Validate inputs
5511 if (!$intent_id || empty($intent_label) || empty($phrases_input)) {
5512 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5513 return;
5514 }
5515
5516 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5517 if (empty($phrases_array)) {
5518 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5519 return;
5520 }
5521
5522 // Generate embeddings with improved error handling
5523 $vectors = [];
5524 $failed_phrases = [];
5525
5526 foreach ($phrases_array as $phrase) {
5527 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5528 if (is_array($embedding_vector)) {
5529 $vectors[] = $embedding_vector;
5530 } else {
5531 $failed_phrases[] = $phrase;
5532 }
5533 }
5534
5535 if (!empty($failed_phrases)) {
5536 $this->handle_embedding_error(
5537 sprintf(
5538 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5539 implode(', ', $failed_phrases)
5540 )
5541 );
5542 return;
5543 }
5544
5545 if (empty($vectors)) {
5546 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5547 return;
5548 }
5549
5550 $combined_vector = $this->mxchat_average_vectors($vectors);
5551 $serialized_vector = maybe_serialize($combined_vector);
5552
5553 // Update the database
5554 global $wpdb;
5555 $table_name = $wpdb->prefix . 'mxchat_intents';
5556
5557 $result = $wpdb->update(
5558 $table_name,
5559 array(
5560 'intent_label' => $intent_label,
5561 'phrases' => implode(', ', $phrases_array),
5562 'embedding_vector' => $serialized_vector,
5563 'similarity_threshold' => $similarity_threshold,
5564 'enabled_bots' => $enabled_bots_json, // Include enabled_bots in update
5565 ),
5566 array('id' => $intent_id),
5567 array('%s', '%s', '%s', '%f', '%s'), // Format: string, string, string, float, string
5568 array('%d') // Where format: integer
5569 );
5570
5571 if (false === $result) {
5572 $this->handle_embedding_error(__('Failed to update action in database.', 'mxchat'));
5573 return;
5574 }
5575
5576 // Set success message and redirect
5577 set_transient('mxchat_admin_notice_success', __('Intent updated successfully!', 'mxchat'), 60);
5578
5579 $redirect_url = add_query_arg(
5580 array(
5581 'page' => 'mxchat-actions'
5582 ),
5583 admin_url('admin.php')
5584 );
5585 wp_safe_redirect($redirect_url);
5586 exit;
5587 }
5588 public function mxchat_handle_add_intent() {
5589 if (!current_user_can('manage_options')) {
5590 wp_die(esc_html__('Unauthorized user', 'mxchat'));
5591 }
5592 check_admin_referer('mxchat_add_intent_nonce');
5593 global $wpdb;
5594 $table_name = $wpdb->prefix . 'mxchat_intents';
5595
5596 // Sanitize and get form data
5597 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5598 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5599 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5600
5601 // Get similarity threshold from form (convert percentage to decimal)
5602 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5603
5604 // Handle enabled_bots
5605 $enabled_bots = isset($_POST['enabled_bots']) ? $_POST['enabled_bots'] : array('default');
5606 $enabled_bots = array_map('sanitize_text_field', $enabled_bots);
5607
5608 // Ensure default is always included for backward compatibility with existing actions
5609 if (!in_array('default', $enabled_bots)) {
5610 $enabled_bots[] = 'default';
5611 }
5612
5613 $enabled_bots_json = json_encode($enabled_bots);
5614
5615 // Validate required fields
5616 if (empty($intent_label) || empty($callback_function) || empty($phrases_input)) {
5617 $this->handle_embedding_error(__('Invalid input. Please ensure all fields are filled out.', 'mxchat'));
5618 return;
5619 }
5620
5621 // Validate callback function
5622 $available_callbacks = $this->mxchat_get_available_callbacks();
5623 if (!array_key_exists($callback_function, $available_callbacks)) {
5624 $this->handle_embedding_error(__('Invalid callback function selected.', 'mxchat'));
5625 return;
5626 }
5627
5628 // Check if this is an add-on promotional placeholder (not a real action)
5629 if (!empty($available_callbacks[$callback_function]['addon_promo'])) {
5630 $addon_name = isset($available_callbacks[$callback_function]['addon_name']) ? $available_callbacks[$callback_function]['addon_name'] : __('an add-on', 'mxchat');
5631 $this->handle_embedding_error(sprintf(
5632 __('This action requires the %s to be installed and activated.', 'mxchat'),
5633 $addon_name
5634 ));
5635 return;
5636 }
5637
5638 // Process phrases
5639 $phrases_array = array_map('sanitize_text_field', array_filter(array_map('trim', explode(',', $phrases_input))));
5640 if (empty($phrases_array)) {
5641 $this->handle_embedding_error(__('Please enter at least one valid phrase.', 'mxchat'));
5642 return;
5643 }
5644
5645 // Generate embeddings with improved error handling
5646 $vectors = [];
5647 $failed_phrases = [];
5648 foreach ($phrases_array as $phrase) {
5649 $embedding_vector = $this->mxchat_generate_embedding($phrase, $this->options['api_key']);
5650 if (is_array($embedding_vector)) {
5651 $vectors[] = $embedding_vector;
5652 } else {
5653 $failed_phrases[] = $phrase;
5654 }
5655 }
5656
5657 // Check for embedding failures
5658 if (!empty($failed_phrases)) {
5659 $this->handle_embedding_error(
5660 sprintf(
5661 __('Error generating embeddings for phrases: %s. Check your embedding API.', 'mxchat'),
5662 implode(', ', $failed_phrases)
5663 )
5664 );
5665 return;
5666 }
5667
5668 if (empty($vectors)) {
5669 $this->handle_embedding_error(__('No valid embeddings generated. Please check your phrases.', 'mxchat'));
5670 return;
5671 }
5672
5673 // Create combined vector and insert into database
5674 $combined_vector = $this->mxchat_average_vectors($vectors);
5675 $serialized_vector = maybe_serialize($combined_vector);
5676
5677 $result = $wpdb->insert($table_name, [
5678 'intent_label' => $intent_label,
5679 'phrases' => implode(', ', $phrases_array),
5680 'embedding_vector' => $serialized_vector,
5681 'callback_function' => $callback_function,
5682 'similarity_threshold' => $similarity_threshold,
5683 'enabled_bots' => $enabled_bots_json, // NEW field
5684 ]);
5685
5686 if ($result === false) {
5687 $this->handle_embedding_error(__('Database error: ', 'mxchat') . $wpdb->last_error);
5688 return;
5689 }
5690
5691 // Set success message and redirect
5692 set_transient('mxchat_admin_notice_success', __('New intent added successfully!', 'mxchat'), 60);
5693 wp_safe_redirect(admin_url('admin.php?page=mxchat-actions'));
5694 exit;
5695 }
5696
5697
5698
5699
5700 private function handle_embedding_error($message, $redirect = true) {
5701 // Store the error message in the existing transient
5702 set_transient('mxchat_admin_notice_error', $message, 60);
5703
5704 if ($redirect) {
5705 // Redirect back to the actions page
5706 $redirect_url = add_query_arg(
5707 array(
5708 'page' => 'mxchat-actions'
5709 ),
5710 admin_url('admin.php')
5711 );
5712 wp_safe_redirect($redirect_url);
5713 exit;
5714 }
5715 }
5716
5717 /**
5718 * AJAX handler to fetch actions list for the new split-panel UI
5719 */
5720 public function mxchat_fetch_actions_list() {
5721 check_ajax_referer('mxchat_actions_nonce', 'security');
5722
5723 if (!current_user_can('manage_options')) {
5724 wp_send_json_error(__('Unauthorized', 'mxchat'));
5725 }
5726
5727 global $wpdb;
5728 $table_name = $wpdb->prefix . 'mxchat_intents';
5729
5730 $page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
5731 $per_page = isset($_POST['per_page']) ? min(100, max(1, intval($_POST['per_page']))) : 50;
5732 $offset = ($page - 1) * $per_page;
5733 $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
5734 $callback_filter = isset($_POST['callback_filter']) ? sanitize_text_field($_POST['callback_filter']) : '';
5735 $sort_order = isset($_POST['sort_order']) && $_POST['sort_order'] === 'asc' ? 'ASC' : 'DESC';
5736
5737 // Build WHERE clause
5738 $where = '1=1';
5739 $params = array();
5740
5741 if ($search) {
5742 $search_like = '%' . $wpdb->esc_like($search) . '%';
5743 $where .= ' AND (intent_label LIKE %s OR phrases LIKE %s)';
5744 $params[] = $search_like;
5745 $params[] = $search_like;
5746 }
5747
5748 if ($callback_filter) {
5749 $where .= ' AND callback_function = %s';
5750 $params[] = $callback_filter;
5751 }
5752
5753 // Get total count
5754 $count_query = "SELECT COUNT(*) FROM $table_name WHERE $where";
5755 if (!empty($params)) {
5756 $count_query = $wpdb->prepare($count_query, $params);
5757 }
5758 $total_actions = $wpdb->get_var($count_query);
5759
5760 // Get actions
5761 $query = "SELECT * FROM $table_name WHERE $where ORDER BY id $sort_order LIMIT %d OFFSET %d";
5762 $all_params = array_merge($params, array($per_page, $offset));
5763 $actions = $wpdb->get_results($wpdb->prepare($query, $all_params));
5764
5765 // Get available callbacks for labels/icons
5766 $available_callbacks = $this->mxchat_get_available_callbacks();
5767
5768 // Prefetch individual phrase counts for all fetched actions
5769 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5770 $phrase_counts = array();
5771 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5772 $action_ids = wp_list_pluck($actions, 'id');
5773 if (!empty($action_ids)) {
5774 $id_placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5775 $count_results = $wpdb->get_results($wpdb->prepare(
5776 "SELECT intent_id, COUNT(*) as cnt FROM $phrases_table WHERE intent_id IN ($id_placeholders) GROUP BY intent_id",
5777 $action_ids
5778 ));
5779 foreach ($count_results as $row) {
5780 $phrase_counts[$row->intent_id] = intval($row->cnt);
5781 }
5782 }
5783 }
5784
5785 // Format actions for response
5786 $formatted_actions = array();
5787 foreach ($actions as $action) {
5788 $callback_data = isset($available_callbacks[$action->callback_function])
5789 ? $available_callbacks[$action->callback_function]
5790 : array('label' => $action->callback_function, 'icon' => 'admin-generic');
5791
5792 $enabled_bots = json_decode($action->enabled_bots, true);
5793 if (!is_array($enabled_bots)) {
5794 $enabled_bots = array('default');
5795 }
5796
5797 $formatted_actions[] = array(
5798 'id' => intval($action->id),
5799 'label' => $action->intent_label,
5800 'phrases' => $action->phrases,
5801 'callback_function' => $action->callback_function,
5802 'callback_label' => $callback_data['label'],
5803 'icon' => isset($callback_data['icon']) ? $callback_data['icon'] : 'admin-generic',
5804 'threshold' => round($action->similarity_threshold * 100),
5805 'enabled' => (bool) $action->enabled,
5806 'enabled_bots' => $enabled_bots,
5807 'has_legacy_vector' => !empty($action->embedding_vector),
5808 'individual_phrase_count' => isset($phrase_counts[$action->id]) ? $phrase_counts[$action->id] : 0,
5809 );
5810 }
5811
5812 $total_pages = ceil($total_actions / $per_page);
5813 $showing_start = $total_actions > 0 ? $offset + 1 : 0;
5814 $showing_end = min($offset + $per_page, $total_actions);
5815
5816 wp_send_json_success(array(
5817 'actions' => $formatted_actions,
5818 'page' => $page,
5819 'per_page' => $per_page,
5820 'total_actions' => intval($total_actions),
5821 'total_pages' => $total_pages,
5822 'showing_start' => $showing_start,
5823 'showing_end' => $showing_end,
5824 ));
5825 }
5826
5827 /**
5828 * AJAX handler to toggle action enabled status
5829 */
5830 public function mxchat_toggle_action_status() {
5831 check_ajax_referer('mxchat_actions_nonce', 'security');
5832
5833 if (!current_user_can('manage_options')) {
5834 wp_send_json_error(__('Unauthorized', 'mxchat'));
5835 }
5836
5837 $action_id = isset($_POST['action_id']) ? intval($_POST['action_id']) : 0;
5838 $enabled = isset($_POST['enabled']) ? intval($_POST['enabled']) : 0;
5839
5840 if (!$action_id) {
5841 wp_send_json_error(__('Invalid action ID', 'mxchat'));
5842 }
5843
5844 global $wpdb;
5845 $table_name = $wpdb->prefix . 'mxchat_intents';
5846
5847 $result = $wpdb->update(
5848 $table_name,
5849 array('enabled' => $enabled ? 1 : 0),
5850 array('id' => $action_id),
5851 array('%d'),
5852 array('%d')
5853 );
5854
5855 if ($result === false) {
5856 wp_send_json_error(__('Failed to update action status', 'mxchat'));
5857 }
5858
5859 wp_send_json_success(array('enabled' => (bool) $enabled));
5860 }
5861
5862 /**
5863 * AJAX handler to bulk delete actions
5864 */
5865 public function mxchat_bulk_delete_actions() {
5866 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
5867
5868 if (!current_user_can('manage_options')) {
5869 wp_send_json_error(__('Unauthorized', 'mxchat'));
5870 }
5871
5872 $action_ids = isset($_POST['action_ids']) ? array_map('intval', (array) $_POST['action_ids']) : array();
5873
5874 if (empty($action_ids)) {
5875 wp_send_json_error(__('No actions selected', 'mxchat'));
5876 }
5877
5878 global $wpdb;
5879 $table_name = $wpdb->prefix . 'mxchat_intents';
5880
5881 $placeholders = implode(',', array_fill(0, count($action_ids), '%d'));
5882 $query = $wpdb->prepare("DELETE FROM $table_name WHERE id IN ($placeholders)", $action_ids);
5883 $result = $wpdb->query($query);
5884
5885 if ($result === false) {
5886 wp_send_json_error(__('Failed to delete actions', 'mxchat'));
5887 }
5888
5889 // Also delete individual phrases for these intents
5890 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5891 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
5892 $wpdb->query($wpdb->prepare("DELETE FROM $phrases_table WHERE intent_id IN ($placeholders)", $action_ids));
5893 }
5894
5895 wp_send_json_success(array('deleted' => $result));
5896 }
5897
5898 /**
5899 * AJAX handler to add a new intent/action
5900 */
5901 public function mxchat_add_intent_ajax() {
5902 check_ajax_referer('mxchat_add_intent_nonce', 'security');
5903
5904 if (!current_user_can('manage_options')) {
5905 wp_send_json_error(__('Unauthorized', 'mxchat'));
5906 }
5907
5908 global $wpdb;
5909 $table_name = $wpdb->prefix . 'mxchat_intents';
5910
5911 // Sanitize input
5912 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
5913 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
5914 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
5915 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
5916 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
5917
5918 // Validate
5919 // Check if using new individual phrases mode or legacy mode
5920 $individual_phrases = isset($_POST['individual_phrases']) ? array_filter(array_map('sanitize_text_field', (array) $_POST['individual_phrases'])) : array();
5921 $use_individual = !empty($individual_phrases);
5922
5923 // Validate required fields (phrases not required when using individual mode)
5924 if (empty($intent_label) || empty($callback_function)) {
5925 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5926 }
5927 if (!$use_individual && empty($phrases_input)) {
5928 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
5929 }
5930
5931 // Ensure default bot is included
5932 if (!in_array('default', $enabled_bots)) {
5933 $enabled_bots[] = 'default';
5934 }
5935 $enabled_bots_json = json_encode($enabled_bots);
5936
5937 if ($use_individual) {
5938 // New mode: individual phrases each get their own vector
5939 // Insert the intent row with empty legacy fields
5940 $result = $wpdb->insert(
5941 $table_name,
5942 array(
5943 'intent_label' => $intent_label,
5944 'phrases' => '',
5945 'embedding_vector' => '',
5946 'similarity_threshold' => $similarity_threshold,
5947 'callback_function' => $callback_function,
5948 'enabled' => 1,
5949 'enabled_bots' => $enabled_bots_json,
5950 )
5951 );
5952
5953 if ($result === false) {
5954 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
5955 }
5956
5957 $intent_id = $wpdb->insert_id;
5958
5959 // Insert each phrase individually with its own embedding
5960 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
5961 $failed_phrases = array();
5962 foreach ($individual_phrases as $phrase) {
5963 $phrase = trim($phrase);
5964 if (empty($phrase)) continue;
5965
5966 $embedding_vector = $this->mxchat_generate_embedding($phrase);
5967 if (is_wp_error($embedding_vector)) {
5968 $failed_phrases[] = $phrase;
5969 continue;
5970 }
5971
5972 $wpdb->insert(
5973 $phrases_table,
5974 array(
5975 'intent_id' => $intent_id,
5976 'phrase' => $phrase,
5977 'embedding_vector' => maybe_serialize($embedding_vector),
5978 )
5979 );
5980 }
5981
5982 $response = array('id' => $intent_id);
5983 if (!empty($failed_phrases)) {
5984 $response['failed_phrases'] = $failed_phrases;
5985 }
5986 wp_send_json_success($response);
5987
5988 } else {
5989 // Legacy mode: combine all phrases into one embedding (backwards compatible)
5990 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
5991 if (empty($phrases_array)) {
5992 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
5993 }
5994
5995 // Generate embedding (combine phrases into single string for embedding)
5996 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
5997 if (is_wp_error($embedding_vector)) {
5998 // Fallback: store without embedding
5999 $serialized_vector = null;
6000 } else {
6001 $serialized_vector = maybe_serialize($embedding_vector);
6002 }
6003
6004 // Insert
6005 $result = $wpdb->insert(
6006 $table_name,
6007 array(
6008 'intent_label' => $intent_label,
6009 'phrases' => implode(', ', $phrases_array),
6010 'embedding_vector' => $serialized_vector,
6011 'similarity_threshold' => $similarity_threshold,
6012 'callback_function' => $callback_function,
6013 'enabled' => 1,
6014 'enabled_bots' => $enabled_bots_json,
6015 )
6016 );
6017
6018 if ($result === false) {
6019 wp_send_json_error(__('Failed to add action to database.', 'mxchat'));
6020 }
6021
6022 wp_send_json_success(array('id' => $wpdb->insert_id));
6023 }
6024 }
6025
6026 /**
6027 * AJAX handler to edit an existing intent/action
6028 */
6029 public function mxchat_edit_intent_ajax() {
6030 check_ajax_referer('mxchat_edit_intent', 'security');
6031
6032 if (!current_user_can('manage_options')) {
6033 wp_send_json_error(__('Unauthorized', 'mxchat'));
6034 }
6035
6036 global $wpdb;
6037 $table_name = $wpdb->prefix . 'mxchat_intents';
6038
6039 // Sanitize input
6040 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6041 $intent_label = isset($_POST['intent_label']) ? sanitize_text_field($_POST['intent_label']) : '';
6042 $phrases_input = isset($_POST['phrases']) ? sanitize_textarea_field($_POST['phrases']) : '';
6043 $callback_function = isset($_POST['callback_function']) ? sanitize_text_field($_POST['callback_function']) : '';
6044 $similarity_threshold = isset($_POST['similarity_threshold']) ? floatval($_POST['similarity_threshold']) / 100 : 0.85;
6045 $enabled_bots = isset($_POST['enabled_bots']) ? array_map('sanitize_text_field', (array) $_POST['enabled_bots']) : array('default');
6046
6047 // Validate
6048 if (!$intent_id || empty($intent_label)) {
6049 wp_send_json_error(__('Please fill in all required fields.', 'mxchat'));
6050 }
6051
6052 // Check if phrases are managed individually (empty phrases_input means individual mode)
6053 $uses_individual_phrases = empty($phrases_input);
6054
6055 if ($uses_individual_phrases) {
6056 // Individual phrase mode: only update non-phrase fields, skip embedding regeneration
6057 $update_data = array(
6058 'intent_label' => $intent_label,
6059 'similarity_threshold' => $similarity_threshold,
6060 'callback_function' => $callback_function,
6061 'enabled_bots' => json_encode($enabled_bots),
6062 );
6063 } else {
6064 // Legacy mode: process phrases and regenerate embedding (backwards compatible for add-ons)
6065 $phrases_array = array_filter(array_map('trim', preg_split('/[\n,]+/', $phrases_input)));
6066 if (empty($phrases_array)) {
6067 wp_send_json_error(__('Please provide at least one trigger phrase.', 'mxchat'));
6068 }
6069
6070 // Generate new embedding (combine phrases into single string for embedding)
6071 $embedding_vector = $this->mxchat_generate_embedding(implode(' ', $phrases_array));
6072 if (is_wp_error($embedding_vector)) {
6073 // Keep existing embedding
6074 $update_data = array(
6075 'intent_label' => $intent_label,
6076 'phrases' => implode(', ', $phrases_array),
6077 'similarity_threshold' => $similarity_threshold,
6078 'callback_function' => $callback_function,
6079 'enabled_bots' => json_encode($enabled_bots),
6080 );
6081 } else {
6082 $serialized_vector = maybe_serialize($embedding_vector);
6083 $update_data = array(
6084 'intent_label' => $intent_label,
6085 'phrases' => implode(', ', $phrases_array),
6086 'embedding_vector' => $serialized_vector,
6087 'similarity_threshold' => $similarity_threshold,
6088 'callback_function' => $callback_function,
6089 'enabled_bots' => json_encode($enabled_bots),
6090 );
6091 }
6092 }
6093
6094 // Ensure default bot is included
6095 if (!in_array('default', $enabled_bots)) {
6096 $enabled_bots[] = 'default';
6097 }
6098 $update_data['enabled_bots'] = json_encode($enabled_bots);
6099
6100 $result = $wpdb->update(
6101 $table_name,
6102 $update_data,
6103 array('id' => $intent_id),
6104 null,
6105 array('%d')
6106 );
6107
6108 if ($result === false) {
6109 wp_send_json_error(__('Failed to update action.', 'mxchat'));
6110 }
6111
6112 wp_send_json_success(array('updated' => true));
6113 }
6114
6115 /**
6116 * AJAX handler to delete a single intent/action
6117 */
6118 public function mxchat_delete_intent_ajax() {
6119 check_ajax_referer('mxchat_delete_intent_nonce', 'security');
6120
6121 if (!current_user_can('manage_options')) {
6122 wp_send_json_error(__('Unauthorized', 'mxchat'));
6123 }
6124
6125 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6126
6127 if (!$intent_id) {
6128 wp_send_json_error(__('Invalid action ID', 'mxchat'));
6129 }
6130
6131 global $wpdb;
6132 $table_name = $wpdb->prefix . 'mxchat_intents';
6133
6134 $result = $wpdb->delete($table_name, array('id' => $intent_id), array('%d'));
6135
6136 if ($result === false) {
6137 wp_send_json_error(__('Failed to delete action', 'mxchat'));
6138 }
6139
6140 // Also delete individual phrases for this intent
6141 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6142 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
6143 $wpdb->delete($phrases_table, array('intent_id' => $intent_id), array('%d'));
6144 }
6145
6146 wp_send_json_success(array('deleted' => true));
6147 }
6148
6149 /**
6150 * AJAX handler to add a single phrase with its own embedding to an intent
6151 */
6152 public function mxchat_add_phrase_ajax() {
6153 check_ajax_referer('mxchat_add_phrase_nonce', 'security');
6154
6155 if (!current_user_can('manage_options')) {
6156 wp_send_json_error(__('Unauthorized', 'mxchat'));
6157 }
6158
6159 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6160 $phrase = isset($_POST['phrase']) ? sanitize_text_field($_POST['phrase']) : '';
6161
6162 if (!$intent_id || empty($phrase)) {
6163 wp_send_json_error(__('Please provide an intent ID and phrase.', 'mxchat'));
6164 }
6165
6166 // Verify the intent exists
6167 global $wpdb;
6168 $intents_table = $wpdb->prefix . 'mxchat_intents';
6169 $intent = $wpdb->get_row($wpdb->prepare("SELECT id FROM $intents_table WHERE id = %d", $intent_id));
6170 if (!$intent) {
6171 wp_send_json_error(__('Action not found.', 'mxchat'));
6172 }
6173
6174 // Generate embedding for this single phrase
6175 $embedding_vector = $this->mxchat_generate_embedding($phrase);
6176 if (is_wp_error($embedding_vector)) {
6177 wp_send_json_error(__('Failed to generate embedding: ', 'mxchat') . $embedding_vector->get_error_message());
6178 }
6179
6180 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6181 $result = $wpdb->insert(
6182 $phrases_table,
6183 array(
6184 'intent_id' => $intent_id,
6185 'phrase' => $phrase,
6186 'embedding_vector' => maybe_serialize($embedding_vector),
6187 )
6188 );
6189
6190 if ($result === false) {
6191 wp_send_json_error(__('Failed to add phrase.', 'mxchat'));
6192 }
6193
6194 wp_send_json_success(array('id' => $wpdb->insert_id, 'phrase' => $phrase));
6195 }
6196
6197 /**
6198 * AJAX handler to delete a single phrase from wp_mxchat_intent_phrases
6199 */
6200 public function mxchat_delete_phrase_ajax() {
6201 check_ajax_referer('mxchat_delete_phrase_nonce', 'security');
6202
6203 if (!current_user_can('manage_options')) {
6204 wp_send_json_error(__('Unauthorized', 'mxchat'));
6205 }
6206
6207 $phrase_id = isset($_POST['phrase_id']) ? intval($_POST['phrase_id']) : 0;
6208 if (!$phrase_id) {
6209 wp_send_json_error(__('Invalid phrase ID.', 'mxchat'));
6210 }
6211
6212 global $wpdb;
6213 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6214 $result = $wpdb->delete($phrases_table, array('id' => $phrase_id), array('%d'));
6215
6216 if ($result === false) {
6217 wp_send_json_error(__('Failed to delete phrase.', 'mxchat'));
6218 }
6219
6220 wp_send_json_success(array('deleted' => true));
6221 }
6222
6223 /**
6224 * AJAX handler to fetch individual phrases for an intent
6225 */
6226 public function mxchat_get_phrases_ajax() {
6227 check_ajax_referer('mxchat_get_phrases_nonce', 'security');
6228
6229 if (!current_user_can('manage_options')) {
6230 wp_send_json_error(__('Unauthorized', 'mxchat'));
6231 }
6232
6233 $intent_id = isset($_POST['intent_id']) ? intval($_POST['intent_id']) : 0;
6234 if (!$intent_id) {
6235 wp_send_json_error(__('Invalid intent ID.', 'mxchat'));
6236 }
6237
6238 global $wpdb;
6239 $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
6240
6241 $phrases = array();
6242 if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
6243 $phrases = $wpdb->get_results($wpdb->prepare(
6244 "SELECT id, phrase, created_at FROM $phrases_table WHERE intent_id = %d ORDER BY created_at ASC",
6245 $intent_id
6246 ));
6247 }
6248
6249 wp_send_json_success(array('phrases' => $phrases));
6250 }
6251
6252 /**
6253 * AJAX handler to clear legacy phrases and embedding from the main intents table
6254 */
6255 public function mxchat_delete_legacy_phrases_ajax() {
6256 check_ajax_referer('mxchat_delete_legacy_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 $table_name = $wpdb->prefix . 'mxchat_intents';
6269
6270 $result = $wpdb->update(
6271 $table_name,
6272 array('phrases' => '', 'embedding_vector' => ''),
6273 array('id' => $intent_id),
6274 array('%s', '%s'),
6275 array('%d')
6276 );
6277
6278 if ($result === false) {
6279 wp_send_json_error(__('Failed to clear legacy phrases.', 'mxchat'));
6280 }
6281
6282 wp_send_json_success(array('cleared' => true));
6283 }
6284
6285 /**
6286 * Enhanced get_available_callbacks function with form action exclusion
6287 *
6288 * @param bool $grouped Whether to return callbacks grouped by category
6289 * @param bool $include_all Whether to include all potential actions (even if add-on not installed)
6290 * @return array Callbacks data with icons, descriptions and availability status
6291 */
6292 private function mxchat_get_available_callbacks($grouped = false, $include_all = true) {
6293 // Load WordPress plugin functions if needed
6294 if (!function_exists('get_plugins')) {
6295 require_once ABSPATH . 'wp-admin/includes/plugin.php';
6296 }
6297
6298 // Get active plugins
6299 $active_plugins = get_option('active_plugins', array());
6300
6301 // Functions to exclude from the action selector only if Pro is activated
6302 // If user doesn't have Pro, show these so they can see what they're missing
6303 $excluded_when_pro_active_functions = array(
6304 'mxchat_handle_form_collection' // Forms add-on action
6305 );
6306
6307 // Always excluded functions (regardless of Pro status)
6308 $always_excluded_functions = array();
6309
6310 // Combine exclusion lists based on Pro activation status
6311 $excluded_functions = $always_excluded_functions;
6312 if ($this->is_activated) {
6313 // Only exclude add-on managed functions if Pro is active
6314 $excluded_functions = array_merge($excluded_functions, $excluded_when_pro_active_functions);
6315 }
6316
6317 // Define add-on plugin files and their corresponding action functions
6318 $addon_plugins = array(
6319 'mxchat-woo/mxchat-woo.php' => array(
6320 'functions' => array(
6321 'mxchat_handle_product_recommendations',
6322 'mxchat_handle_order_history',
6323 'mxchat_show_product_card',
6324 'mxchat_add_to_cart',
6325 'mxchat_checkout_redirect',
6326 'mxchat_handle_featured_products'
6327 ),
6328 'name' => __('WooCommerce Add-on', 'mxchat'),
6329 'pro_required' => true
6330 ),
6331 'mxchat-perplexity/mxchat-perplexity.php' => array(
6332 'functions' => array('mxchat_perplexity_research'),
6333 'name' => __('Perplexity Add-on', 'mxchat'),
6334 'pro_required' => true
6335 ),
6336 'mxchat-forms/mxchat-forms.php' => array(
6337 'functions' => array('mxchat_handle_form_collection'),
6338 'name' => __('Forms Add-on', 'mxchat'),
6339 'pro_required' => true
6340 ),
6341 // Add other add-ons and their functions here
6342 );
6343
6344 // Get the functions that are provided by active add-ons
6345 $addon_provided_functions = array();
6346 $addon_function_mapping = array(); // Maps functions to their add-on info
6347
6348 // Check which add-ons are active
6349 foreach ($addon_plugins as $plugin_file => $addon_info) {
6350 $is_active = in_array($plugin_file, $active_plugins);
6351
6352 // For each function in this addon
6353 foreach ($addon_info['functions'] as $function) {
6354 // Consider a function installed only if:
6355 // 1. The add-on is active AND
6356 // 2. Either it doesn't require Pro OR Pro is activated
6357 $is_installed = $is_active && (!$addon_info['pro_required'] || $this->is_activated);
6358
6359 // If the add-on is installed, mark this function as provided by an add-on
6360 if ($is_installed) {
6361 $addon_provided_functions[] = $function;
6362 }
6363
6364 // Store addon info for this function regardless of installation status
6365 $addon_function_mapping[$function] = array(
6366 'addon' => basename(dirname($plugin_file)),
6367 'addon_name' => $addon_info['name'],
6368 'pro_required' => $addon_info['pro_required'],
6369 'is_active' => $is_active,
6370 'is_installed' => $is_installed
6371 );
6372 }
6373 }
6374
6375 // Core callbacks - always available in the base plugin
6376 $core_callbacks = array(
6377 'mxchat_handle_email_capture' => array(
6378 'label' => __('Loops Email Capture', 'mxchat'),
6379 'pro_only' => false,
6380 'group' => __('Customer Engagement', 'mxchat'),
6381 'icon' => 'email-alt',
6382 'description' => __('Collect visitor emails for your mailing list in Loops', 'mxchat'),
6383 'addon' => false, // Not from an add-on
6384 'installed' => true // Always installed with base plugin
6385 ),
6386 'mxchat_handle_search_request' => array(
6387 'label' => __('Brave Web Search', 'mxchat'),
6388 'pro_only' => false,
6389 'group' => __('Search Features', 'mxchat'),
6390 'icon' => 'search',
6391 'description' => __('Let users search the web directly from the chat (requires a Brave Search API key)', 'mxchat'),
6392 'addon' => false,
6393 'installed' => true
6394 ),
6395 'mxchat_handle_image_search_request' => array(
6396 'label' => __('Brave Image Search', 'mxchat'),
6397 'pro_only' => false,
6398 'group' => __('Search Features', 'mxchat'),
6399 'icon' => 'format-image',
6400 'description' => __('Search and display images in the chat conversation (requires a Brave Search API key)', 'mxchat'),
6401 'addon' => false,
6402 'installed' => true
6403 ),
6404 // Pro core features - check is_activated property
6405 'mxchat_generate_image' => array(
6406 'label' => __('Generate Image (OpenAI)', 'mxchat'),
6407 'pro_only' => false,
6408 'group' => __('Other Features', 'mxchat'),
6409 'icon' => 'art',
6410 'description' => __('Create images with GPT Image from OpenAI (requires OpenAI API key)', 'mxchat'),
6411 'addon' => false,
6412 'installed' => true
6413 ),
6414 'mxchat_generate_gemini_image' => array(
6415 'label' => __('Generate Image (Gemini)', 'mxchat'),
6416 'pro_only' => false,
6417 'group' => __('Other Features', 'mxchat'),
6418 'icon' => 'art',
6419 'description' => __('Create images with Imagen from Google (requires Gemini API key)', 'mxchat'),
6420 'addon' => false,
6421 'installed' => true
6422 ),
6423 'mxchat_handle_pdf_discussion' => array(
6424 'label' => __('Chat with PDF', 'mxchat'),
6425 'pro_only' => false,
6426 'group' => __('Other Features', 'mxchat'),
6427 'icon' => 'media-document',
6428 'description' => __('Answer questions about uploaded PDF documents', 'mxchat'),
6429 'addon' => false,
6430 'installed' => true
6431 ),
6432 'mxchat_live_agent_handover' => array(
6433 'label' => __('Slack Live Agent', 'mxchat'),
6434 'pro_only' => false,
6435 'group' => __('Customer Engagement', 'mxchat'),
6436 'icon' => 'admin-users',
6437 'description' => __('Transfer conversation to a human support agent on Slack', 'mxchat'),
6438 'addon' => false,
6439 'installed' => true
6440 ),
6441 'mxchat_telegram_live_agent_handover' => array(
6442 'label' => __('Telegram Live Agent', 'mxchat'),
6443 'pro_only' => false,
6444 'group' => __('Customer Engagement', 'mxchat'),
6445 'icon' => 'format-chat',
6446 'description' => __('Transfer conversation to a human support agent on Telegram', 'mxchat'),
6447 'addon' => false,
6448 'installed' => true
6449 ),
6450 'mxchat_webhook_live_agent_handover' => array(
6451 'label' => __('Webhook Live Agent', 'mxchat'),
6452 'pro_only' => false,
6453 'group' => __('Customer Engagement', 'mxchat'),
6454 'icon' => 'external',
6455 'description' => __('Send the conversation to your own webhook URL so your team can follow up', 'mxchat'),
6456 'addon' => false,
6457 'installed' => true
6458 ),
6459 'mxchat_handle_switch_to_chatbot_intent' => array(
6460 'label' => __('Back to Chatbot', 'mxchat'),
6461 'pro_only' => false,
6462 'group' => __('Customer Engagement', 'mxchat'),
6463 'icon' => 'backup',
6464 'description' => __('Return from live agent mode to AI chatbot', 'mxchat'),
6465 'addon' => false,
6466 'installed' => true
6467 ),
6468 );
6469
6470 // Add-on callbacks with placeholders - only include if the add-on is NOT active
6471 // These are promotional/informational only — not selectable as real actions
6472 $addon_callbacks = array(
6473 // WooCommerce Add-on
6474 'mxchat_handle_product_recommendations' => array(
6475 'label' => __('Product Recommendations', 'mxchat'),
6476 'pro_only' => false,
6477 'addon_promo' => true,
6478 'group' => __('WooCommerce Features', 'mxchat'),
6479 'icon' => 'cart',
6480 'description' => __('Suggest products based on customer preferences', 'mxchat'),
6481 ),
6482 'mxchat_handle_order_history' => array(
6483 'label' => __('Order History', 'mxchat'),
6484 'pro_only' => false,
6485 'addon_promo' => true,
6486 'group' => __('WooCommerce Features', 'mxchat'),
6487 'icon' => 'clipboard',
6488 'description' => __('Allow customers to check their order status', 'mxchat'),
6489 ),
6490 'mxchat_show_product_card' => array(
6491 'label' => __('Show Product Card', 'mxchat'),
6492 'pro_only' => false,
6493 'addon_promo' => true,
6494 'group' => __('WooCommerce Features', 'mxchat'),
6495 'icon' => 'products',
6496 'description' => __('Display product information in the chat', 'mxchat'),
6497 ),
6498 'mxchat_add_to_cart' => array(
6499 'label' => __('Add to Cart', 'mxchat'),
6500 'pro_only' => false,
6501 'addon_promo' => true,
6502 'group' => __('WooCommerce Features', 'mxchat'),
6503 'icon' => 'plus-alt',
6504 'description' => __('Add products to cart directly from chat', 'mxchat'),
6505 ),
6506 'mxchat_checkout_redirect' => array(
6507 'label' => __('Proceed to Checkout', 'mxchat'),
6508 'pro_only' => false,
6509 'addon_promo' => true,
6510 'group' => __('WooCommerce Features', 'mxchat'),
6511 'icon' => 'arrow-right-alt',
6512 'description' => __('Redirect customer to checkout page', 'mxchat'),
6513 ),
6514 'mxchat_handle_featured_products' => array(
6515 'label' => __('Featured Products Showcase', 'mxchat'),
6516 'pro_only' => false,
6517 'addon_promo' => true,
6518 'group' => __('WooCommerce Features', 'mxchat'),
6519 'icon' => 'star-filled',
6520 'description' => __('Display a curated selection of products with an AI-generated message', 'mxchat'),
6521 ),
6522
6523 // Perplexity Add-on
6524 'mxchat_perplexity_research' => array(
6525 'label' => __('Perplexity Research', 'mxchat'),
6526 'pro_only' => false,
6527 'addon_promo' => true,
6528 'group' => __('Search Features', 'mxchat'),
6529 'icon' => 'book-alt',
6530 'description' => __('Allows the chatbot to search the web for accurate, up-to-date answers', 'mxchat'),
6531 ),
6532
6533 // Forms Add-on
6534 'mxchat_handle_form_collection' => array(
6535 'label' => __('Form Collection', 'mxchat'),
6536 'pro_only' => false,
6537 'addon_promo' => true,
6538 'group' => __('Form Features', 'mxchat'),
6539 'icon' => 'feedback',
6540 'description' => __('Collect user information through custom forms in chat', 'mxchat'),
6541 ),
6542 );
6543
6544 // Enhance add-on callbacks with installation status and addon info
6545 foreach ($addon_callbacks as $function => $data) {
6546 if (isset($addon_function_mapping[$function])) {
6547 $addon_info = $addon_function_mapping[$function];
6548
6549 $addon_callbacks[$function]['addon'] = $addon_info['addon'];
6550 $addon_callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6551 $addon_callbacks[$function]['installed'] = $addon_info['is_installed'];
6552
6553 // Set pro_only based on add-on configuration
6554 $addon_callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6555 } else {
6556 $addon_callbacks[$function]['addon'] = 'unknown';
6557 $addon_callbacks[$function]['addon_name'] = __('Unknown Add-on', 'mxchat');
6558 $addon_callbacks[$function]['installed'] = false;
6559 }
6560 }
6561
6562 // Initialize callbacks with core features
6563 $callbacks = $core_callbacks;
6564
6565 // Get callbacks from active add-ons
6566 $active_addon_callbacks = apply_filters('mxchat_available_callbacks', array());
6567
6568 // Add placeholder callbacks only for add-ons that aren't active
6569 if ($include_all) {
6570 foreach ($addon_callbacks as $function => $data) {
6571 // Skip placeholders for functions provided by active add-ons
6572 if (in_array($function, $addon_provided_functions)) {
6573 continue;
6574 }
6575
6576 // Skip excluded functions
6577 if (in_array($function, $excluded_functions)) {
6578 continue;
6579 }
6580
6581 // Add the placeholder
6582 $callbacks[$function] = $data;
6583 }
6584 }
6585
6586 // Add callbacks from active add-ons (will override placeholders)
6587 foreach ($active_addon_callbacks as $function => $data) {
6588 // Skip excluded functions
6589 if (in_array($function, $excluded_functions)) {
6590 continue;
6591 }
6592
6593 // Always include callbacks from add-ons
6594 $callbacks[$function] = $data;
6595
6596 // Ensure they have the proper add-on info
6597 if (isset($addon_function_mapping[$function])) {
6598 $addon_info = $addon_function_mapping[$function];
6599 $callbacks[$function]['addon'] = $addon_info['addon'];
6600 $callbacks[$function]['addon_name'] = $addon_info['addon_name'];
6601 $callbacks[$function]['installed'] = $addon_info['is_installed'];
6602 $callbacks[$function]['pro_only'] = $addon_info['pro_required'];
6603 }
6604 }
6605
6606 // Just before returning callbacks, sort them to prioritize free features
6607 if (!$grouped) {
6608 // Create temporary arrays for sorting
6609 $free_callbacks = array();
6610 $pro_callbacks = array();
6611
6612 // Split callbacks into free and pro
6613 foreach ($callbacks as $key => $data) {
6614 if (isset($data['pro_only']) && $data['pro_only']) {
6615 $pro_callbacks[$key] = $data;
6616 } else {
6617 $free_callbacks[$key] = $data;
6618 }
6619 }
6620
6621 // Merge with free callbacks first
6622 $callbacks = array_merge($free_callbacks, $pro_callbacks);
6623 }
6624
6625 // Return grouped structure if requested
6626 if ($grouped) {
6627 $grouped_callbacks = array();
6628 foreach ($callbacks as $key => $data) {
6629 $group_label = isset($data['group']) ? $data['group'] : __('Other Features', 'mxchat');
6630
6631 // Ensure we carry forward all the new fields in grouped mode
6632 $callback_data = array(
6633 'label' => $data['label'],
6634 'pro_only' => isset($data['pro_only']) ? $data['pro_only'] : false,
6635 'icon' => isset($data['icon']) ? $data['icon'] : 'admin-generic',
6636 'description' => isset($data['description']) ? $data['description'] : __('Custom action for your chatbot', 'mxchat'),
6637 'addon' => isset($data['addon']) ? $data['addon'] : false,
6638 'addon_name' => isset($data['addon_name']) ? $data['addon_name'] : '',
6639 'installed' => isset($data['installed']) ? $data['installed'] : true
6640 );
6641
6642 $grouped_callbacks[$group_label][$key] = $callback_data;
6643 }
6644
6645 // Sort within each group to prioritize free features
6646 foreach ($grouped_callbacks as $group => $items) {
6647 $free_items = array();
6648 $pro_items = array();
6649
6650 foreach ($items as $key => $data) {
6651 if (isset($data['pro_only']) && $data['pro_only']) {
6652 $pro_items[$key] = $data;
6653 } else {
6654 $free_items[$key] = $data;
6655 }
6656 }
6657
6658 $grouped_callbacks[$group] = array_merge($free_items, $pro_items);
6659 }
6660
6661 return $grouped_callbacks;
6662 }
6663
6664 return $callbacks;
6665 }
6666
6667 public function mxchat_page_init() {
6668 register_setting(
6669 'mxchat_option_group',
6670 'mxchat_options',
6671 array($this, 'mxchat_sanitize')
6672 );
6673
6674 register_setting(
6675 'mxchat_option_group',
6676 'mxchat_similarity_threshold',
6677 array(
6678 'type' => 'number',
6679 'sanitize_callback' => function($value) {
6680 $value = absint($value);
6681 return min(max($value, 20), 95);
6682 },
6683 'default' => 80,
6684 )
6685 );
6686
6687 // Chatbot Settings Section
6688 add_settings_section(
6689 'mxchat_chatbot_section',
6690 esc_html__('Chatbot Settings', 'mxchat'),
6691 null,
6692 'mxchat-chatbot'
6693 );
6694
6695 // API Keys Settings Section
6696 add_settings_section(
6697 'mxchat_api_keys_section',
6698 esc_html__('API Keys', 'mxchat'),
6699 array($this, 'mxchat_api_keys_section_callback'),
6700 'mxchat-api-keys'
6701 );
6702
6703 // OpenAI API Key
6704 add_settings_field(
6705 'api_key',
6706 esc_html__('OpenAI API Key', 'mxchat'),
6707 array($this, 'api_key_callback'),
6708 'mxchat-api-keys',
6709 'mxchat_api_keys_section'
6710 );
6711
6712 // X.AI API Key
6713 add_settings_field(
6714 'xai_api_key',
6715 esc_html__('X.AI API Key', 'mxchat'),
6716 array($this, 'xai_api_key_callback'),
6717 'mxchat-api-keys',
6718 'mxchat_api_keys_section'
6719 );
6720
6721 // Claude API Key
6722 add_settings_field(
6723 'claude_api_key',
6724 esc_html__('Claude API Key', 'mxchat'),
6725 array($this, 'claude_api_key_callback'),
6726 'mxchat-api-keys',
6727 'mxchat_api_keys_section'
6728 );
6729
6730 // DeepSeek API Key
6731 add_settings_field(
6732 'deepseek_api_key',
6733 esc_html__('DeepSeek API Key', 'mxchat'),
6734 array($this, 'deepseek_api_key_callback'),
6735 'mxchat-api-keys',
6736 'mxchat_api_keys_section'
6737 );
6738
6739 // Google Gemini API Key
6740 add_settings_field(
6741 'gemini_api_key',
6742 esc_html__('Google Gemini API Key', 'mxchat'),
6743 array($this, 'gemini_api_key_callback'),
6744 'mxchat-api-keys',
6745 'mxchat_api_keys_section'
6746 );
6747
6748 // Voyage AI API Key
6749 add_settings_field(
6750 'voyage_api_key',
6751 esc_html__('Voyage AI API Key', 'mxchat'),
6752 array($this, 'voyage_api_key_callback'),
6753 'mxchat-api-keys',
6754 'mxchat_api_keys_section'
6755 );
6756
6757 // OpenRouter API Key
6758 add_settings_field(
6759 'openrouter_api_key',
6760 esc_html__('OpenRouter API Key', 'mxchat'),
6761 array($this, 'openrouter_api_key_callback'),
6762 'mxchat-api-keys',
6763 'mxchat_api_keys_section'
6764 );
6765
6766 // Custom (OpenAI-compatible) Provider — for Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
6767 add_settings_field(
6768 'custom_provider',
6769 esc_html__('Custom Provider (OpenAI-compatible)', 'mxchat'),
6770 array($this, 'custom_provider_callback'),
6771 'mxchat-api-keys',
6772 'mxchat_api_keys_section'
6773 );
6774
6775 // Loops API Key
6776 add_settings_field(
6777 'loops_api_key',
6778 esc_html__('Loops API Key', 'mxchat'),
6779 array($this, 'mxchat_loops_api_key_callback'),
6780 'mxchat-api-keys',
6781 'mxchat_api_keys_section'
6782 );
6783
6784 // Brave Search API Key
6785 add_settings_field(
6786 'brave_api_key',
6787 __('Brave API Key', 'mxchat'),
6788 array($this, 'mxchat_brave_api_key_callback'),
6789 'mxchat-api-keys',
6790 'mxchat_api_keys_section'
6791 );
6792
6793 // Similarity Threshold Slider
6794 add_settings_field(
6795 'similarity_threshold', // Field ID
6796 esc_html__('Similarity Threshold', 'mxchat'), // Field title
6797 array($this, 'mxchat_similarity_threshold_callback'), // Callback function
6798 'mxchat-chatbot', // Page
6799 'mxchat_chatbot_section' // Section
6800 );
6801
6802 // RAG Sources Limit Slider
6803 add_settings_field(
6804 'rag_sources_limit', // Field ID
6805 esc_html__('RAG Sources Limit', 'mxchat'), // Field title
6806 array($this, 'mxchat_rag_sources_limit_callback'), // Callback function
6807 'mxchat-chatbot', // Page
6808 'mxchat_chatbot_section' // Section
6809 );
6810
6811 // RAG Chunks Limit Slider
6812 add_settings_field(
6813 'rag_chunks_limit', // Field ID
6814 esc_html__('RAG Chunks Limit', 'mxchat'), // Field title
6815 array($this, 'mxchat_rag_chunks_limit_callback'), // Callback function
6816 'mxchat-chatbot', // Page
6817 'mxchat_chatbot_section' // Section
6818 );
6819
6820 add_settings_field(
6821 'append_to_body',
6822 esc_html__('Auto-Display Chatbot', 'mxchat'),
6823 array($this, 'mxchat_append_to_body_callback'),
6824 'mxchat-chatbot',
6825 'mxchat_chatbot_section'
6826 );
6827
6828 add_settings_field(
6829 'contextual_awareness_toggle',
6830 esc_html__('Contextual Awareness', 'mxchat'),
6831 array($this, 'mxchat_contextual_awareness_callback'),
6832 'mxchat-chatbot',
6833 'mxchat_chatbot_section'
6834 );
6835
6836 add_settings_field(
6837 'citation_links_toggle',
6838 esc_html__('Citation Links', 'mxchat'),
6839 array($this, 'mxchat_citation_links_toggle_callback'),
6840 'mxchat-chatbot',
6841 'mxchat_chatbot_section'
6842 );
6843
6844 // Strip unapproved links (plan 58f8b4) — the enforcement half of the old
6845 // Citation Links conflation, now its own control.
6846 add_settings_field(
6847 'strip_unapproved_links_toggle',
6848 esc_html__('Strip Unapproved Links', 'mxchat'),
6849 array($this, 'mxchat_strip_unapproved_links_toggle_callback'),
6850 'mxchat-chatbot',
6851 'mxchat_chatbot_section'
6852 );
6853
6854 // Satisfaction rating toggle (plan-a5b006).
6855 add_settings_field(
6856 'satisfaction_rating_enabled',
6857 esc_html__('Satisfaction Rating Prompt', 'mxchat'),
6858 array($this, 'mxchat_satisfaction_rating_toggle_callback'),
6859 'mxchat-chatbot',
6860 'mxchat_chatbot_section'
6861 );
6862
6863 // Satisfaction rating customization (plan-141a12, plan-29caac):
6864 // the 5 customization fields are now inline-rendered inside
6865 // mxchat_satisfaction_rating_toggle_callback's sub-options wrapper.
6866
6867 add_settings_field(
6868 'enable_streaming_toggle',
6869 esc_html__('Enable Streaming', 'mxchat'),
6870 array($this, 'enable_streaming_toggle_callback'),
6871 'mxchat-chatbot',
6872 'mxchat_chatbot_section', // Same section as your working toggle
6873 array(
6874 'class' => 'mxchat-setting-row streaming-setting',
6875 'style' => 'display: none;' // Hidden by default, shown when OpenAI/Claude selected
6876 )
6877 );
6878
6879
6880 add_settings_field(
6881 'model',
6882 esc_html__('Chat Model', 'mxchat'),
6883 array($this, 'mxchat_model_callback'),
6884 'mxchat-chatbot',
6885 'mxchat_chatbot_section'
6886 );
6887
6888 add_settings_field(
6889 'embedding_model',
6890 esc_html__('Embedding Model', 'mxchat'),
6891 array($this, 'embedding_model_callback'),
6892 'mxchat-chatbot',
6893 'mxchat_chatbot_section'
6894 );
6895
6896 add_settings_field(
6897 'system_prompt_instructions',
6898 esc_html__('AI Instructions (Behavior)', 'mxchat'),
6899 array($this, 'system_prompt_instructions_callback'),
6900 'mxchat-chatbot',
6901 'mxchat_chatbot_section'
6902 );
6903
6904
6905 add_settings_field(
6906 'top_bar_title',
6907 esc_html__('Top Bar Title', 'mxchat'),
6908 array($this, 'mxchat_top_bar_title_callback'),
6909 'mxchat-chatbot',
6910 'mxchat_chatbot_section'
6911 );
6912
6913 add_settings_field(
6914 'ai_agent_text',
6915 esc_html__('AI Agent Text', 'mxchat'),
6916 array($this, 'mxchat_ai_agent_text_callback'),
6917 'mxchat-chatbot',
6918 'mxchat_chatbot_section'
6919 );
6920
6921 add_settings_field(
6922 'enable_email_block',
6923 esc_html__('Require Email To Chat', 'mxchat'),
6924 array($this, 'enable_email_block_callback'),
6925 'mxchat-chatbot',
6926 'mxchat_chatbot_section'
6927 );
6928
6929 add_settings_field(
6930 'email_blocker_header_content',
6931 esc_html__('Require Email Chat Content', 'mxchat'),
6932 array($this, 'email_blocker_header_content_callback'),
6933 'mxchat-chatbot',
6934 'mxchat_chatbot_section'
6935 );
6936
6937 add_settings_field(
6938 'email_blocker_button_text',
6939 esc_html__('Require Email Chat Button Text', 'mxchat'),
6940 [$this, 'email_blocker_button_text_callback'],
6941 'mxchat-chatbot',
6942 'mxchat_chatbot_section'
6943 );
6944
6945 add_settings_field(
6946 'enable_name_field',
6947 esc_html__('Require Name Field', 'mxchat'),
6948 array($this, 'enable_name_field_callback'),
6949 'mxchat-chatbot',
6950 'mxchat_chatbot_section'
6951 );
6952
6953 add_settings_field(
6954 'name_field_placeholder',
6955 esc_html__('Name Field Placeholder', 'mxchat'),
6956 array($this, 'name_field_placeholder_callback'),
6957 'mxchat-chatbot',
6958 'mxchat_chatbot_section'
6959 );
6960
6961 add_settings_field(
6962 'intro_message',
6963 esc_html__('Introductory Message', 'mxchat'),
6964 array($this, 'mxchat_intro_message_callback'),
6965 'mxchat-chatbot',
6966 'mxchat_chatbot_section'
6967 );
6968
6969 add_settings_field(
6970 'input_copy',
6971 esc_html__('Input Copy', 'mxchat'),
6972 array($this, 'mxchat_input_copy_callback'),
6973 'mxchat-chatbot',
6974 'mxchat_chatbot_section'
6975 );
6976
6977 add_settings_field(
6978 'pre_chat_message',
6979 esc_html__('Chat Teaser Pop-up', 'mxchat'),
6980 array($this, 'mxchat_pre_chat_message_callback'),
6981 'mxchat-chatbot',
6982 'mxchat_chatbot_section'
6983 );
6984
6985 add_settings_field(
6986 'privacy_toggle',
6987 esc_html__('Toggle Privacy Notice', 'mxchat'),
6988 array($this, 'mxchat_privacy_toggle_callback'),
6989 'mxchat-chatbot',
6990 'mxchat_chatbot_section'
6991 );
6992
6993 add_settings_field(
6994 'complianz_toggle',
6995 esc_html__('Enable Complianz', 'mxchat'),
6996 array($this, 'mxchat_complianz_toggle_callback'),
6997 'mxchat-chatbot',
6998 'mxchat_chatbot_section'
6999 );
7000
7001 add_settings_field(
7002 'link_target_toggle',
7003 esc_html__('Open Links in a New Tab', 'mxchat'),
7004 array($this, 'mxchat_link_target_toggle_callback'),
7005 'mxchat-chatbot',
7006 'mxchat_chatbot_section'
7007 );
7008
7009 add_settings_field(
7010 'chat_persistence_toggle',
7011 esc_html__('Enable Chat Persistence', 'mxchat'),
7012 array($this, 'mxchat_chat_persistence_toggle_callback'),
7013 'mxchat-chatbot',
7014 'mxchat_chatbot_section'
7015 );
7016
7017 add_settings_field(
7018 'print_button_enabled',
7019 esc_html__('Show Download Transcript Button', 'mxchat'),
7020 array($this, 'mxchat_print_button_toggle_callback'),
7021 'mxchat-chatbot',
7022 'mxchat_chatbot_section'
7023 );
7024
7025 add_settings_field(
7026 'reset_chat_enabled',
7027 esc_html__('Show Start-New-Chat Button', 'mxchat'),
7028 array($this, 'mxchat_reset_chat_toggle_callback'),
7029 'mxchat-chatbot',
7030 'mxchat_chatbot_section'
7031 );
7032
7033 add_settings_field(
7034 'reset_chat_label',
7035 esc_html__('Start-New-Chat Button Label', 'mxchat'),
7036 array($this, 'mxchat_reset_chat_label_callback'),
7037 'mxchat-chatbot',
7038 'mxchat_chatbot_section'
7039 );
7040
7041 add_settings_field(
7042 'popular_question_1',
7043 esc_html__('Quick Question 1', 'mxchat'),
7044 array($this, 'mxchat_popular_question_1_callback'),
7045 'mxchat-chatbot',
7046 'mxchat_chatbot_section'
7047 );
7048
7049 add_settings_field(
7050 'popular_question_2',
7051 esc_html__('Quick Question 2', 'mxchat'),
7052 array($this, 'mxchat_popular_question_2_callback'),
7053 'mxchat-chatbot',
7054 'mxchat_chatbot_section'
7055 );
7056
7057 add_settings_field(
7058 'popular_question_3',
7059 esc_html__('Quick Question 3', 'mxchat'),
7060 array($this, 'mxchat_popular_question_3_callback'),
7061 'mxchat-chatbot',
7062 'mxchat_chatbot_section'
7063 );
7064
7065 add_settings_field(
7066 'additional_popular_questions',
7067 esc_html__('Additional Quick Questions', 'mxchat'),
7068 array($this, 'mxchat_additional_popular_questions_callback'),
7069 'mxchat-chatbot',
7070 'mxchat_chatbot_section'
7071 );
7072
7073
7074 add_settings_field(
7075 'rate_limits',
7076 __('Rate Limits Settings', 'mxchat'),
7077 array($this, 'mxchat_rate_limits_callback'),
7078 'mxchat-chatbot',
7079 'mxchat_chatbot_section'
7080 );
7081
7082 // Loops Settings Section
7083 add_settings_section(
7084 'mxchat_loops_section',
7085 esc_html__('Loops Settings', 'mxchat'),
7086 null,
7087 'mxchat-embed'
7088 );
7089
7090 // Loops Settings Fields (API Key moved to API Keys tab)
7091 add_settings_field(
7092 'loops_mailing_list',
7093 esc_html__('Loops Mailing List', 'mxchat'),
7094 array($this, 'mxchat_loops_mailing_list_callback'),
7095 'mxchat-embed',
7096 'mxchat_loops_section'
7097 );
7098
7099 add_settings_field(
7100 'triggered_phrase_response',
7101 esc_html__('Triggered Phrase Response', 'mxchat'),
7102 array($this, 'mxchat_triggered_phrase_response_callback'),
7103 'mxchat-embed',
7104 'mxchat_loops_section'
7105 );
7106
7107 add_settings_field(
7108 'email_capture_response',
7109 esc_html__('Email Capture Response', 'mxchat'),
7110 array($this, 'mxchat_email_capture_response_callback'),
7111 'mxchat-embed',
7112 'mxchat_loops_section'
7113 );
7114
7115 // Brave Search Settings Fields
7116 add_settings_section(
7117 'mxchat_brave_section',
7118 __('Brave Search Settings', 'mxchat'),
7119 array($this, 'mxchat_brave_section_callback'),
7120 'mxchat-embed'
7121 );
7122
7123 // Brave API Key moved to API Keys tab
7124 add_settings_field(
7125 'brave_image_count',
7126 __('Number of Images to Return', 'mxchat'),
7127 array($this, 'mxchat_brave_image_count_callback'),
7128 'mxchat-embed',
7129 'mxchat_brave_section'
7130 );
7131
7132 add_settings_field(
7133 'brave_safe_search',
7134 __('Safe Search', 'mxchat'),
7135 array($this, 'mxchat_brave_safe_search_callback'),
7136 'mxchat-embed',
7137 'mxchat_brave_section'
7138 );
7139
7140 add_settings_field(
7141 'brave_news_count',
7142 __('Number of News Articles', 'mxchat'),
7143 array($this, 'mxchat_brave_news_count_callback'),
7144 'mxchat-embed',
7145 'mxchat_brave_section'
7146 );
7147
7148 add_settings_field(
7149 'brave_country',
7150 __('Country', 'mxchat'),
7151 array($this, 'mxchat_brave_country_callback'),
7152 'mxchat-embed',
7153 'mxchat_brave_section'
7154 );
7155
7156 add_settings_field(
7157 'brave_language',
7158 __('Language', 'mxchat'),
7159 array($this, 'mxchat_brave_language_callback'),
7160 'mxchat-embed',
7161 'mxchat_brave_section'
7162 );
7163
7164 // Chat with PDF Intent Settings Fields
7165 add_settings_section(
7166 'mxchat_pdf_intent_section',
7167 __('Toolbar Settings & Intents', 'mxchat'),
7168 array($this, 'mxchat_pdf_intent_section_callback'),
7169 'mxchat-embed'
7170 );
7171
7172 add_settings_field(
7173 'chat_toolbar_toggle',
7174 __('Show Chat Toolbar', 'mxchat'),
7175 array($this, 'mxchat_chat_toolbar_toggle_callback'),
7176 'mxchat-embed',
7177 'mxchat_pdf_intent_section'
7178 );
7179
7180 // PDF Upload Button Toggle
7181 add_settings_field(
7182 'show_pdf_upload_button',
7183 __('Show PDF Upload Button', 'mxchat'),
7184 array($this, 'mxchat_show_pdf_upload_button_callback'),
7185 'mxchat-embed',
7186 'mxchat_pdf_intent_section'
7187 );
7188
7189 // Word Upload Button Toggle
7190 add_settings_field(
7191 'show_word_upload_button',
7192 __('Show Word Upload Button', 'mxchat'),
7193 array($this, 'mxchat_show_word_upload_button_callback'),
7194 'mxchat-embed',
7195 'mxchat_pdf_intent_section'
7196 );
7197
7198 add_settings_field(
7199 'pdf_intent_trigger_text',
7200 __('Intent Trigger Text', 'mxchat'),
7201 array($this, 'mxchat_pdf_intent_trigger_text_callback'),
7202 'mxchat-embed',
7203 'mxchat_pdf_intent_section'
7204 );
7205
7206 add_settings_field(
7207 'pdf_intent_success_text',
7208 __('Success Text', 'mxchat'),
7209 array($this, 'mxchat_pdf_intent_success_text_callback'),
7210 'mxchat-embed',
7211 'mxchat_pdf_intent_section'
7212 );
7213
7214 add_settings_field(
7215 'pdf_intent_error_text',
7216 __('Error Text', 'mxchat'),
7217 array($this, 'mxchat_pdf_intent_error_text_callback'),
7218 'mxchat-embed',
7219 'mxchat_pdf_intent_section'
7220 );
7221
7222 // Add PDF Maximum Pages Field
7223 add_settings_field(
7224 'pdf_max_pages',
7225 __('Maximum Document Pages', 'mxchat'),
7226 array($this, 'mxchat_pdf_max_pages_callback'),
7227 'mxchat-embed',
7228 'mxchat_pdf_intent_section'
7229 );
7230
7231 // Live Agent Settings Fields
7232 add_settings_section(
7233 'mxchat_live_agent_section',
7234 __('Live Agent Settings', 'mxchat'),
7235 array($this, 'mxchat_live_agent_section_callback'),
7236 'mxchat-embed'
7237 );
7238
7239 // Live Agent Status Fields (add at top of live agent settings)
7240 add_settings_field(
7241 'live_agent_status',
7242 __('Live Agent Status', 'mxchat'),
7243 array($this, 'mxchat_live_agent_status_callback'),
7244 'mxchat-embed',
7245 'mxchat_live_agent_section'
7246 );
7247
7248 // Slack availability schedule (plans 8ccaa2 + 99d7a4). Each handoff channel
7249 // owns an independent schedule rendered under its own Integrations tab; this
7250 // one governs Slack only. Same callback as Telegram's, parameterized.
7251 add_settings_field(
7252 'live_agent_schedule_slack',
7253 __('Availability Schedule', 'mxchat'),
7254 array($this, 'mxchat_live_agent_schedule_callback'),
7255 'mxchat-embed',
7256 'mxchat_live_agent_section',
7257 array('channel' => 'slack')
7258 );
7259
7260 add_settings_field(
7261 'live_agent_notification_message',
7262 __('Notification Message', 'mxchat'),
7263 array($this, 'mxchat_live_agent_notification_message_callback'),
7264 'mxchat-embed',
7265 'mxchat_live_agent_section'
7266 );
7267
7268 add_settings_field(
7269 'live_agent_away_message',
7270 __('Away Message', 'mxchat'),
7271 array($this, 'mxchat_live_agent_away_message_callback'),
7272 'mxchat-embed',
7273 'mxchat_live_agent_section'
7274 );
7275
7276 add_settings_field(
7277 'live_agent_user_ids',
7278 __('Slack Agent User IDs', 'mxchat'),
7279 array($this, 'mxchat_live_agent_user_ids_callback'),
7280 'mxchat-embed',
7281 'mxchat_live_agent_section'
7282 );
7283
7284 // Shared handoff channel (plan 9f7756): route every handoff into one
7285 // pre-existing channel as threads instead of creating chat-* channels.
7286 add_settings_field(
7287 'live_agent_shared_channel',
7288 __('Shared Handoff Channel', 'mxchat'),
7289 array($this, 'mxchat_live_agent_shared_channel_callback'),
7290 'mxchat-embed',
7291 'mxchat_live_agent_section'
7292 );
7293
7294 // Auto-archive per-conversation chat- channels on !endchat (plan 7458a7).
7295 // Default OFF; never touches the shared handoff channel.
7296 add_settings_field(
7297 'live_agent_archive_on_end_toggle',
7298 __('Archive Channel When Chat Ends', 'mxchat'),
7299 array($this, 'mxchat_live_agent_archive_on_end_callback'),
7300 'mxchat-embed',
7301 'mxchat_live_agent_section'
7302 );
7303
7304 add_settings_field(
7305 'live_agent_webhook_url',
7306 __('Slack Webhook URL', 'mxchat'),
7307 array($this, 'mxchat_live_agent_webhook_url_callback'),
7308 'mxchat-embed',
7309 'mxchat_live_agent_section'
7310 );
7311
7312 add_settings_field(
7313 'live_agent_secret_key',
7314 __('Slack Secret Key', 'mxchat'),
7315 array($this, 'mxchat_live_agent_secret_key_callback'),
7316 'mxchat-embed',
7317 'mxchat_live_agent_section'
7318 );
7319
7320 // Live Agent Integration Fields
7321 add_settings_field(
7322 'live_agent_bot_token',
7323 __('Slack Bot OAuth Token', 'mxchat'),
7324 array($this, 'mxchat_live_agent_bot_token_callback'),
7325 'mxchat-embed',
7326 'mxchat_live_agent_section'
7327 );
7328
7329 // Telegram Integration Section
7330 add_settings_section(
7331 'mxchat_telegram_section',
7332 __('Telegram Settings', 'mxchat'),
7333 array($this, 'mxchat_telegram_section_callback'),
7334 'mxchat-embed'
7335 );
7336
7337 add_settings_field(
7338 'telegram_status',
7339 __('Live Agent Status', 'mxchat'),
7340 array($this, 'mxchat_telegram_status_callback'),
7341 'mxchat-embed',
7342 'mxchat_telegram_section'
7343 );
7344
7345 // Telegram availability schedule (plan 99d7a4) — independent of Slack's,
7346 // rendered right under the Telegram status toggle it extends.
7347 add_settings_field(
7348 'live_agent_schedule_telegram',
7349 __('Availability Schedule', 'mxchat'),
7350 array($this, 'mxchat_live_agent_schedule_callback'),
7351 'mxchat-embed',
7352 'mxchat_telegram_section',
7353 array('channel' => 'telegram')
7354 );
7355
7356 add_settings_field(
7357 'telegram_notification_message',
7358 __('Notification Message', 'mxchat'),
7359 array($this, 'mxchat_telegram_notification_message_callback'),
7360 'mxchat-embed',
7361 'mxchat_telegram_section'
7362 );
7363
7364 add_settings_field(
7365 'telegram_away_message',
7366 __('Away Message', 'mxchat'),
7367 array($this, 'mxchat_telegram_away_message_callback'),
7368 'mxchat-embed',
7369 'mxchat_telegram_section'
7370 );
7371
7372 add_settings_field(
7373 'telegram_bot_token',
7374 __('Telegram Bot Token', 'mxchat'),
7375 array($this, 'mxchat_telegram_bot_token_callback'),
7376 'mxchat-embed',
7377 'mxchat_telegram_section'
7378 );
7379
7380 add_settings_field(
7381 'telegram_group_id',
7382 __('Telegram Group ID', 'mxchat'),
7383 array($this, 'mxchat_telegram_group_id_callback'),
7384 'mxchat-embed',
7385 'mxchat_telegram_section'
7386 );
7387
7388 add_settings_field(
7389 'telegram_webhook_secret',
7390 __('Webhook Secret Token', 'mxchat'),
7391 array($this, 'mxchat_telegram_webhook_secret_callback'),
7392 'mxchat-embed',
7393 'mxchat_telegram_section'
7394 );
7395
7396 // Webhook Handoff Section (plan d88e22) — the third live-agent destination.
7397 // Outbound-only: handoffs POST to the owner's URL; the chat stays in AI mode.
7398 add_settings_section(
7399 'mxchat_webhook_handoff_section',
7400 __('Webhook Handoff Settings', 'mxchat'),
7401 array($this, 'mxchat_webhook_handoff_section_callback'),
7402 'mxchat-embed'
7403 );
7404
7405 add_settings_field(
7406 'webhook_handoff_status',
7407 __('Live Agent Status', 'mxchat'),
7408 array($this, 'mxchat_webhook_handoff_status_callback'),
7409 'mxchat-embed',
7410 'mxchat_webhook_handoff_section'
7411 );
7412
7413 // Webhook availability schedule — independent of Slack's and Telegram's,
7414 // same parameterized editor (plans 8ccaa2 + 99d7a4 + d88e22).
7415 add_settings_field(
7416 'live_agent_schedule_webhook',
7417 __('Availability Schedule', 'mxchat'),
7418 array($this, 'mxchat_live_agent_schedule_callback'),
7419 'mxchat-embed',
7420 'mxchat_webhook_handoff_section',
7421 array('channel' => 'webhook')
7422 );
7423
7424 add_settings_field(
7425 'webhook_handoff_notification_message',
7426 __('Notification Message', 'mxchat'),
7427 array($this, 'mxchat_webhook_handoff_notification_message_callback'),
7428 'mxchat-embed',
7429 'mxchat_webhook_handoff_section'
7430 );
7431
7432 add_settings_field(
7433 'webhook_handoff_away_message',
7434 __('Away Message', 'mxchat'),
7435 array($this, 'mxchat_webhook_handoff_away_message_callback'),
7436 'mxchat-embed',
7437 'mxchat_webhook_handoff_section'
7438 );
7439
7440 add_settings_field(
7441 'webhook_handoff_url',
7442 __('Webhook URL', 'mxchat'),
7443 array($this, 'mxchat_webhook_handoff_url_callback'),
7444 'mxchat-embed',
7445 'mxchat_webhook_handoff_section'
7446 );
7447
7448 add_settings_field(
7449 'webhook_handoff_secret',
7450 __('Signing Secret', 'mxchat'),
7451 array($this, 'mxchat_webhook_handoff_secret_callback'),
7452 'mxchat-embed',
7453 'mxchat_webhook_handoff_section'
7454 );
7455
7456 // General Settings Section
7457 add_settings_section(
7458 'mxchat_general_section',
7459 esc_html__('YouTube Tutorials', 'mxchat'),
7460 null,
7461 'mxchat-general'
7462 );
7463 }
7464
7465 public function mxchat_prompts_page_init() {
7466 register_setting(
7467 'mxchat_prompts_options',
7468 'mxchat_prompts_options',
7469 array(
7470 'type' => 'array',
7471 'description' => __('MXChat Knowledge Base Settings', 'mxchat'),
7472 'default' => array(
7473 'mxchat_auto_sync_posts' => 0,
7474 'mxchat_auto_sync_pages' => 0,
7475 'mxchat_use_pinecone' => 0,
7476 'mxchat_pinecone_api_key' => '',
7477 'mxchat_pinecone_environment' => '',
7478 'mxchat_pinecone_index' => '',
7479 'mxchat_pinecone_host' => '',
7480 ),
7481 'sanitize_callback' => array($this, 'sanitize_prompts_options'),
7482 )
7483 );
7484
7485 add_action('admin_notices', array($this, 'sync_settings_notice'));
7486 }
7487
7488 public function mxchat_transcripts_page_init() {
7489 register_setting(
7490 'mxchat_transcripts_options',
7491 'mxchat_transcripts_options',
7492 array(
7493 'type' => 'array',
7494 'description' => __('MXChat Transcripts Notification Settings', 'mxchat'),
7495 'default' => array(
7496 'mxchat_enable_notifications' => 0,
7497 'mxchat_notification_email' => get_option('admin_email'),
7498 'mxchat_auto_delete_transcripts' => 'never',
7499 ),
7500 'sanitize_callback' => array($this, 'sanitize_transcripts_options'),
7501 )
7502 );
7503
7504 add_settings_section(
7505 'mxchat_transcripts_notification_section',
7506 esc_html__('Chat Notification Settings', 'mxchat'),
7507 array($this, 'mxchat_transcripts_notification_section_callback'),
7508 'mxchat-transcripts'
7509 );
7510
7511 add_settings_field(
7512 'mxchat_enable_notifications',
7513 esc_html__('Enable Chat Notifications', 'mxchat'),
7514 array($this, 'mxchat_enable_notifications_callback'),
7515 'mxchat-transcripts',
7516 'mxchat_transcripts_notification_section'
7517 );
7518
7519 add_settings_field(
7520 'mxchat_notification_email',
7521 esc_html__('Notification Email Address', 'mxchat'),
7522 array($this, 'mxchat_notification_email_callback'),
7523 'mxchat-transcripts',
7524 'mxchat_transcripts_notification_section'
7525 );
7526
7527 add_settings_field(
7528 'mxchat_auto_delete_transcripts',
7529 esc_html__('Auto-Delete Old Transcripts', 'mxchat'),
7530 array($this, 'mxchat_auto_delete_transcripts_callback'),
7531 'mxchat-transcripts',
7532 'mxchat_transcripts_notification_section'
7533 );
7534
7535 add_settings_field(
7536 'mxchat_retention_days',
7537 esc_html__('Custom Retention (Days)', 'mxchat'),
7538 array($this, 'mxchat_retention_days_callback'),
7539 'mxchat-transcripts',
7540 'mxchat_transcripts_notification_section'
7541 );
7542
7543 add_settings_field(
7544 'mxchat_auto_email_transcript',
7545 esc_html__('Auto-Email Full Transcript', 'mxchat'),
7546 array($this, 'mxchat_auto_email_transcript_callback'),
7547 'mxchat-transcripts',
7548 'mxchat_transcripts_notification_section'
7549 );
7550 }
7551
7552
7553 /**
7554 * Sanitize all prompts options
7555 *
7556 * @param array $input The unsanitized options array
7557 * @return array The sanitized options array
7558 */
7559 public function sanitize_prompts_options($input) {
7560 // Log the incoming input.
7561 //error_log('Sanitizing inputs: ' . print_r($input, true));
7562
7563 $sanitized = array();
7564
7565 // Boolean options
7566 $sanitized['mxchat_auto_sync_posts'] = isset($input['mxchat_auto_sync_posts']) ? 1 : 0;
7567 $sanitized['mxchat_auto_sync_pages'] = isset($input['mxchat_auto_sync_pages']) ? 1 : 0;
7568 $sanitized['mxchat_use_pinecone'] = !empty($input['mxchat_use_pinecone']) ? 1 : 0;
7569
7570 // API Key: if less than 32 characters, flag as invalid.
7571 $api_key = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? '');
7572 if (!empty($api_key) && strlen($api_key) < 32) {
7573 add_settings_error(
7574 'mxchat_prompts_options',
7575 'invalid_api_key',
7576 __('The Pinecone API key appears to be invalid. Please check your API key.', 'mxchat')
7577 );
7578 $existing_options = get_option('mxchat_prompts_options', array());
7579 $sanitized['mxchat_pinecone_api_key'] = $existing_options['mxchat_pinecone_api_key'] ?? '';
7580 } else {
7581 $sanitized['mxchat_pinecone_api_key'] = $api_key;
7582 }
7583
7584 // Environment and Index Name
7585 $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? '');
7586 $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? '');
7587
7588 // Host: Remove protocol and validate format.
7589 $host = sanitize_text_field($input['mxchat_pinecone_host'] ?? '');
7590 $host = preg_replace('#^https?://#', '', $host);
7591 //error_log('Host after removing protocol: ' . $host);
7592 if (!empty($host)) {
7593 if (!preg_match('/^[\w-]+\.svc\.[\w-]+\.pinecone\.io$/', $host)) {
7594 add_settings_error(
7595 'mxchat_prompts_options',
7596 'invalid_host',
7597 __('The Pinecone host appears to be invalid. It should look like "mxchat-vectors-zrmsquq.svc.aped-4627-b74a.pinecone.io"', 'mxchat')
7598 );
7599 $existing_options = get_option('mxchat_prompts_options', array());
7600 $sanitized['mxchat_pinecone_host'] = $existing_options['mxchat_pinecone_host'] ?? '';
7601 } else {
7602 $sanitized['mxchat_pinecone_host'] = $host;
7603 }
7604 } else {
7605 $sanitized['mxchat_pinecone_host'] = '';
7606 }
7607
7608 //error_log('Final sanitized array: ' . print_r($sanitized, true));
7609
7610 return $sanitized;
7611 }
7612
7613 public function sync_settings_notice() {
7614 // Only show notice on our plugin page
7615 if (!isset($_GET['page']) || $_GET['page'] !== 'mxchat-prompts') {
7616 return;
7617 }
7618
7619 // Check if settings were updated
7620 if (isset($_GET['settings-updated'])) {
7621
7622 ?>
7623 <div class="notice notice-success is-dismissible">
7624 <p><?php esc_html_e('Sync settings updated successfully.', 'mxchat'); ?></p>
7625 <button type="button" class="notice-dismiss"><span class="screen-reader-text"><?php esc_html_e('Dismiss this notice.', 'mxchat'); ?></span></button>
7626 </div>
7627 <?php
7628
7629 }
7630 }
7631 // Add this sanitization function to your class
7632 public function sanitize_sync_setting($input) {
7633 return (bool)$input ? __('1', 'mxchat') : __('', 'mxchat');
7634 }
7635
7636 public function mxchat_rate_limits_callback() {
7637 $all_options = get_option('mxchat_options', []);
7638
7639 // Define available rate limits
7640 $rate_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
7641
7642 // Define available timeframes
7643 $timeframes = array(
7644 'hourly' => __('Per Hour', 'mxchat'),
7645 'daily' => __('Per Day', 'mxchat'),
7646 'weekly' => __('Per Week', 'mxchat'),
7647 'monthly' => __('Per Month', 'mxchat')
7648 );
7649
7650 // Get all roles plus a "logged_out" pseudo-role
7651 $roles = wp_roles()->get_names();
7652 $roles['logged_out'] = __('Logged Out Users', 'mxchat');
7653
7654 // Start the wrapper
7655 echo '<div class="pro-feature-wrapper active">';
7656 echo '<div class="mxchat-rate-limits-container">';
7657
7658 echo '<p class="description" style="margin-bottom: 20px;">' .
7659 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') .
7660 '</p>';
7661
7662 // Add markdown link documentation
7663 echo '<div class="notice notice-info inline" style="margin-bottom: 20px; padding: 10px;">';
7664 echo '<p><strong>' . esc_html__('Markdown Links Supported:', 'mxchat') . '</strong></p>';
7665 echo '<p>' . esc_html__('You can include clickable links in your custom messages using markdown syntax:', 'mxchat') . '</p>';
7666 echo '<ul style="margin-left: 20px;">';
7667 echo '<li><code>[Link text](https://example.com)</code> - Creates a clickable link</li>';
7668 echo '<li><code>[Visit our pricing](https://example.com/pricing)</code> - Link with custom text</li>';
7669 echo '<li><code>Plain URLs like https://example.com will also become clickable</code></li>';
7670 echo '</ul>';
7671 echo '</div>';
7672
7673 // Output the controls for each role
7674 foreach ($roles as $role_id => $role_name) {
7675 // Get saved options or defaults
7676 $default_limit = ($role_id === 'logged_out') ? '10' : '100';
7677 $default_timeframe = 'daily';
7678 $default_message = __('Rate limit exceeded. Please try again later.', 'mxchat');
7679
7680 $selected_limit = isset($all_options['rate_limits'][$role_id]['limit'])
7681 ? $all_options['rate_limits'][$role_id]['limit']
7682 : $default_limit;
7683
7684 $selected_timeframe = isset($all_options['rate_limits'][$role_id]['timeframe'])
7685 ? $all_options['rate_limits'][$role_id]['timeframe']
7686 : $default_timeframe;
7687
7688 $custom_message = isset($all_options['rate_limits'][$role_id]['message'])
7689 ? $all_options['rate_limits'][$role_id]['message']
7690 : $default_message;
7691
7692 // Output the row
7693 echo '<div class="mxchat-rate-limit-row mxchat-autosave-section">';
7694
7695 // Role label
7696 echo '<div class="mxchat-rate-limit-role">' . esc_html($role_name) . '</div>';
7697
7698 // Controls section
7699 echo '<div class="mxchat-rate-limit-controls-wrapper">';
7700
7701 // Rate limit and timeframe controls
7702 echo '<div class="mxchat-rate-limit-controls">';
7703
7704 // Limit dropdown
7705 echo '<div>';
7706 echo '<label for="rate_limits_' . esc_attr($role_id) . '_limit">' . esc_html__('Limit:', 'mxchat') . '</label>';
7707 echo '<select
7708 id="rate_limits_' . esc_attr($role_id) . '_limit"
7709 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][limit]"
7710 class="mxchat-autosave-field">';
7711 foreach ($rate_limits as $limit) {
7712 echo '<option value="' . esc_attr($limit) . '" ' . selected($selected_limit, $limit, false) . '>' . esc_html($limit) . '</option>';
7713 }
7714 echo '</select>';
7715 echo '</div>';
7716
7717 // Timeframe dropdown
7718 echo '<div>';
7719 echo '<label for="rate_limits_' . esc_attr($role_id) . '_timeframe">' . esc_html__('Timeframe:', 'mxchat') . '</label>';
7720 echo '<select
7721 id="rate_limits_' . esc_attr($role_id) . '_timeframe"
7722 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][timeframe]"
7723 class="mxchat-autosave-field">';
7724 foreach ($timeframes as $value => $label) {
7725 echo '<option value="' . esc_attr($value) . '" ' . selected($selected_timeframe, $value, false) . '>' . esc_html($label) . '</option>';
7726 }
7727 echo '</select>';
7728 echo '</div>';
7729
7730 echo '</div>'; // End controls
7731
7732 // Custom message textarea
7733 echo '<div class="mxchat-rate-limit-message">';
7734 echo '<label for="rate_limits_' . esc_attr($role_id) . '_message">' . esc_html__('Custom Message:', 'mxchat') . '</label>';
7735 echo '<textarea
7736 id="rate_limits_' . esc_attr($role_id) . '_message"
7737 name="mxchat_options[rate_limits][' . esc_attr($role_id) . '][message]"
7738 class="mxchat-autosave-field"
7739 placeholder="' . esc_attr__('Enter custom message when rate limit is exceeded', 'mxchat') . '">' .
7740 esc_textarea($custom_message) .
7741 '</textarea>';
7742 echo '<p class="description">' .
7743 esc_html__('Example: Rate limit reached! [Visit our pricing page](https://example.com/pricing) to upgrade.', 'mxchat') .
7744 '</p>';
7745 echo '</div>'; // End message
7746
7747 echo '</div>'; // End controls wrapper
7748
7749 echo '</div>'; // End row
7750 }
7751
7752 echo '</div>'; // End container
7753
7754 echo '</div>'; // End pro-feature-wrapper
7755 }
7756
7757 private function mxchat_add_option_field($id, $title, $callback = '') {
7758 add_settings_field(
7759 $id,
7760 __($title, 'mxchat'),
7761 $callback ? array($this, $callback) : array($this, $id . '_callback'),
7762 'mxchat-max',
7763 'mxchat_setting_section_id',
7764 $id === 'model' ? ['label_for' => 'model'] : []
7765 );
7766 }
7767
7768 // API Keys Section Callback
7769 public function mxchat_api_keys_section_callback() {
7770 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>';
7771 }
7772
7773 // OpenAI API Key
7774 public function api_key_callback() {
7775 $apiKey = isset($this->options['api_key']) ? esc_attr($this->options['api_key']) : '';
7776 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7777
7778 echo '<div class="api-key-wrapper">';
7779 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 . '" />';
7780 echo '<button type="button" id="toggleApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7781 echo '<p class="description">' . esc_html__('Required for OpenAI GPT models and OpenAI embeddings. Get your API key from OpenAI Platform.', 'mxchat') . '</p>';
7782 echo $this->mxchat_provider_key_test_button('openai', 'api_key');
7783 echo '</div>';
7784 }
7785
7786 // X.AI API Key
7787 public function xai_api_key_callback() {
7788 $xaiApiKey = isset($this->options['xai_api_key']) ? esc_attr($this->options['xai_api_key']) : '';
7789 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7790
7791 echo '<div class="api-key-wrapper">';
7792 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 . '" />';
7793 echo '<button type="button" id="toggleXaiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7794 echo '<p class="description">' . esc_html__('Required for X.AI Grok models. Get your API key from X.AI Console.', 'mxchat') . '</p>';
7795 echo $this->mxchat_provider_key_test_button('xai', 'xai_api_key');
7796 echo '</div>';
7797 }
7798 // Claude API Key
7799 public function claude_api_key_callback() {
7800 $claudeApiKey = isset($this->options['claude_api_key']) ? esc_attr($this->options['claude_api_key']) : '';
7801 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7802
7803 echo '<div class="api-key-wrapper">';
7804 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 . '" />';
7805 echo '<button type="button" id="toggleClaudeApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7806 echo '<p class="description">' . esc_html__('Required for Anthropic Claude models. Get your API key from Anthropic Console.', 'mxchat') . '</p>';
7807 echo $this->mxchat_provider_key_test_button('claude', 'claude_api_key');
7808 echo '</div>';
7809 }
7810
7811 // DeepSeek API Key
7812 public function deepseek_api_key_callback() {
7813 $apiKey = isset($this->options['deepseek_api_key']) ? esc_attr($this->options['deepseek_api_key']) : '';
7814 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7815
7816 echo '<div class="api-key-wrapper">';
7817 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 . '" />';
7818 echo '<button type="button" id="toggleDeepSeekApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7819 echo '<p class="description">' . esc_html__('Required for DeepSeek models. Get your API key from DeepSeek Platform.', 'mxchat') . '</p>';
7820 echo $this->mxchat_provider_key_test_button('deepseek', 'deepseek_api_key');
7821 echo '</div>';
7822 }
7823
7824 // Gemini API Key
7825 public function gemini_api_key_callback() {
7826 $geminiApiKey = isset($this->options['gemini_api_key']) ? esc_attr($this->options['gemini_api_key']) : '';
7827 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7828
7829 echo '<div class="api-key-wrapper">';
7830 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 . '" />';
7831 echo '<button type="button" id="toggleGeminiApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7832 echo '<p class="description">' . esc_html__('Required for Google Gemini models and embeddings. Get your API key from Google AI Studio.', 'mxchat') . '</p>';
7833 echo $this->mxchat_provider_key_test_button('gemini', 'gemini_api_key');
7834 echo '</div>';
7835 }
7836
7837
7838 // OpenRouter API Key
7839 public function openrouter_api_key_callback() {
7840 $openrouterApiKey = isset($this->options['openrouter_api_key']) ? esc_attr($this->options['openrouter_api_key']) : '';
7841 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7842
7843 echo '<div class="api-key-wrapper">';
7844 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 . '" />';
7845 echo '<button type="button" id="toggleOpenRouterApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
7846 echo '<p class="description">' . esc_html__('Required for OpenRouter models. Get your API key from OpenRouter.ai', 'mxchat') . '</p>';
7847 echo $this->mxchat_provider_key_test_button('openrouter', 'openrouter_api_key');
7848 echo '</div>';
7849 }
7850
7851 /**
7852 * Renders a "Test key" button + result target next to a built-in provider key
7853 * field, and emits the shared delegated click handler ONCE (static guard). The
7854 * button carries data-target = the key field id so the owner can test the value
7855 * they just typed (test-before-save); the AJAX handler falls back to the saved
7856 * key when the field is empty. Styled with the WP `button` class to match the
7857 * adjacent Custom-provider "Test Connection" button on this same page.
7858 * plan-mxchat-20260623-c41f74.
7859 */
7860 public function mxchat_provider_key_test_button($provider, $target_id) {
7861 static $script_emitted = false;
7862 $nonce = wp_create_nonce('mxchat_test_provider_key');
7863 $html = '<div class="mxchat-key-test" style="margin-top:8px;">'
7864 . '<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>'
7865 . '<span class="mxchat-test-provider-key-result" style="margin-left:10px;font-size:13px;vertical-align:middle;"></span>'
7866 . '</div>';
7867 if (!$script_emitted) {
7868 $script_emitted = true;
7869 $html .= $this->mxchat_provider_key_test_script();
7870 }
7871 return $html;
7872 }
7873
7874 /**
7875 * One-time delegated click handler shared by every .mxchat-test-provider-key
7876 * button. Posts the typed key value + provider to mxchat_test_provider_key and
7877 * renders the success/error message inline. Mirrors the Custom-provider test.
7878 */
7879 private function mxchat_provider_key_test_script() {
7880 $t_testing = esc_js(__('Testing...', 'mxchat'));
7881 $t_valid = esc_js(__('Key is valid.', 'mxchat'));
7882 $t_failed = esc_js(__('Failed', 'mxchat'));
7883 $t_req = esc_js(__('Request failed', 'mxchat'));
7884 return '<script>(function(){'
7885 . 'if (window.__mxchatProviderKeyTestWired) { return; }'
7886 . 'window.__mxchatProviderKeyTestWired = true;'
7887 . 'document.addEventListener("click", function(e){'
7888 . 'var btn = e.target && e.target.closest ? e.target.closest(".mxchat-test-provider-key") : null;'
7889 . 'if (!btn) { return; }'
7890 . 'e.preventDefault();'
7891 . 'var field = btn.getAttribute("data-target") ? document.getElementById(btn.getAttribute("data-target")) : null;'
7892 . 'var out = btn.parentNode ? btn.parentNode.querySelector(".mxchat-test-provider-key-result") : null;'
7893 . 'if (out) { out.textContent = "' . $t_testing . '"; out.style.color = "#646970"; }'
7894 . 'btn.disabled = true;'
7895 . 'var fd = new FormData();'
7896 . 'fd.append("action", "mxchat_test_provider_key");'
7897 . 'fd.append("_wpnonce", btn.getAttribute("data-nonce"));'
7898 . 'fd.append("provider", btn.getAttribute("data-provider") || "");'
7899 . 'fd.append("key", field ? field.value : "");'
7900 . 'fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })'
7901 . '.then(function(r){ return r.json(); })'
7902 . '.then(function(j){'
7903 . 'if (out) {'
7904 . 'if (j && j.success) { out.textContent = "✓ " + ((j.data && j.data.message) ? j.data.message : "' . $t_valid . '"); out.style.color = "#00a32a"; }'
7905 . 'else { out.textContent = "⚠ " + ((j && j.data && j.data.message) ? j.data.message : "' . $t_failed . '"); out.style.color = "#d63638"; }'
7906 . '}'
7907 . 'btn.disabled = false;'
7908 . '})'
7909 . '.catch(function(){ if (out) { out.textContent = "⚠ ' . $t_req . '"; out.style.color = "#d63638"; } btn.disabled = false; });'
7910 . '});'
7911 . '})();</script>';
7912 }
7913
7914 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.
7915 public function custom_provider_callback() {
7916 $base_url = isset($this->options['custom_provider_base_url']) ? esc_attr($this->options['custom_provider_base_url']) : '';
7917 $api_key = isset($this->options['custom_provider_api_key']) ? esc_attr($this->options['custom_provider_api_key']) : '';
7918 $model_name = isset($this->options['custom_provider_model']) ? esc_attr($this->options['custom_provider_model']) : '';
7919 $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? esc_attr($this->options['custom_provider_auth_scheme']) : 'bearer';
7920 $api_version = isset($this->options['custom_provider_api_version']) ? esc_attr($this->options['custom_provider_api_version']) : '';
7921 $use_embed = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
7922 $use_images = !empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on';
7923 $embed_model = isset($this->options['custom_provider_embedding_model']) ? esc_attr($this->options['custom_provider_embedding_model']) : '';
7924 $nonce = wp_create_nonce('mxchat_autosave_nonce');
7925 $test_nonce = wp_create_nonce('mxchat_test_custom_provider');
7926
7927 echo '<style>
7928 .mxchat-cp { max-width: 680px; }
7929 .mxchat-cp .mxchat-cp-intro { margin: 0 0 16px; color: #50575e; font-size: 13px; line-height: 1.5; }
7930 .mxchat-cp .mxchat-cp-row { display: block; margin: 0 0 18px; }
7931 .mxchat-cp .mxchat-cp-row > label { display: block; font-weight: 600; margin: 0 0 6px; color: #1d2327; font-size: 13px; }
7932 .mxchat-cp .mxchat-cp-row > input[type="text"],
7933 .mxchat-cp .mxchat-cp-row > input[type="password"],
7934 .mxchat-cp .mxchat-cp-row > select { display: block; width: 100%; max-width: 480px; margin: 0; }
7935 .mxchat-cp .mxchat-cp-row > .description { display: block; margin: 6px 0 0; color: #646970; font-size: 12px; line-height: 1.5; max-width: 480px; }
7936 .mxchat-cp .mxchat-cp-test { margin-top: 4px; padding-top: 14px; border-top: 1px solid #e5e7eb; }
7937 .mxchat-cp .mxchat-cp-test .mxchat-cp-test-status { display: inline-block; margin-left: 10px; vertical-align: middle; font-size: 13px; }
7938 .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; }
7939 .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; }
7940 .mxchat-cp .mxchat-cp-azure ol { margin: 0; padding: 0 0 0 18px; color: #50575e; font-size: 12px; line-height: 1.7; }
7941 .mxchat-cp .mxchat-cp-azure code { background: #eceefb; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
7942 </style>';
7943
7944 echo '<div class="api-key-wrapper mxchat-cp">';
7945 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>';
7946
7947 // Azure OpenAI quick start — consolidates the 4-field Azure recipe in one scannable callout
7948 // so admins can configure Azure without piecing it together from each field's hint.
7949 echo '<div class="mxchat-cp-azure">';
7950 echo '<span class="mxchat-cp-azure-title">' . esc_html__('Azure OpenAI quick start', 'mxchat') . '</span>';
7951 echo '<ol>';
7952 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>';
7953 echo '<li>' . wp_kses(__('<strong>API Key</strong> → your Azure OpenAI key (required)', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7954 echo '<li>' . wp_kses(__('<strong>Auth Scheme</strong> → <code>api-key header (Azure OpenAI)</code>', 'mxchat'), array('strong' => array(), 'code' => array())) . '</li>';
7955 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>';
7956 echo '</ol>';
7957 echo '</div>';
7958
7959 echo '<div class="mxchat-cp-row">';
7960 echo '<label for="custom_provider_base_url">' . esc_html__('Base URL', 'mxchat') . '</label>';
7961 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 . '" />';
7962 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>';
7963 echo '</div>';
7964
7965 echo '<div class="mxchat-cp-row">';
7966 echo '<label for="custom_provider_api_key">' . esc_html__('API Key (optional)', 'mxchat') . '</label>';
7967 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 . '" />';
7968 echo '<p class="description">' . esc_html__('Leave empty for unauthenticated local servers. Required for Azure / vLLM / hosted endpoints.', 'mxchat') . '</p>';
7969 echo '</div>';
7970
7971 echo '<div class="mxchat-cp-row">';
7972 echo '<label for="custom_provider_model">' . esc_html__('Model Name', 'mxchat') . '</label>';
7973 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 . '" />';
7974 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>';
7975 echo '</div>';
7976
7977 echo '<div class="mxchat-cp-row">';
7978 echo '<label for="custom_provider_auth_scheme">' . esc_html__('Auth Scheme', 'mxchat') . '</label>';
7979 echo '<select id="custom_provider_auth_scheme" name="custom_provider_auth_scheme" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
7980 echo '<option value="bearer"' . selected($auth_scheme, 'bearer', false) . '>' . esc_html__('Authorization: Bearer (OpenAI / Ollama / vLLM / LM Studio)', 'mxchat') . '</option>';
7981 echo '<option value="api-key"' . selected($auth_scheme, 'api-key', false) . '>' . esc_html__('api-key header (Azure OpenAI)', 'mxchat') . '</option>';
7982 echo '</select>';
7983 echo '<p class="description">' . esc_html__('Most OpenAI-compatible servers use Bearer. Azure OpenAI uses the api-key header.', 'mxchat') . '</p>';
7984 echo '</div>';
7985
7986 echo '<div class="mxchat-cp-row">';
7987 echo '<label for="custom_provider_api_version">' . esc_html__('API Version (Azure only)', 'mxchat') . '</label>';
7988 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 . '" />';
7989 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>';
7990 echo '</div>';
7991
7992 // Extended-use checkboxes — opt-in routing of other dispatcher paths through the custom provider.
7993 echo '<div class="mxchat-cp-row">';
7994 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>';
7995 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>';
7996 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>';
7997 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>';
7998 echo '</div>';
7999
8000 echo '<div class="mxchat-cp-row">';
8001 echo '<label for="custom_provider_embedding_model">' . esc_html__('Custom Embedding Model', 'mxchat') . '</label>';
8002 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 . '" />';
8003 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>';
8004 echo '</div>';
8005
8006 echo '<div class="mxchat-cp-test">';
8007 echo '<button type="button" class="button" id="mxchat-test-custom-provider" data-nonce="' . $test_nonce . '">' . esc_html__('Test Connection', 'mxchat') . '</button>';
8008 echo '<span id="mxchat-test-custom-provider-result" class="mxchat-cp-test-status"></span>';
8009 echo '</div>';
8010
8011 echo '<script>(function(){
8012 var btn = document.getElementById("mxchat-test-custom-provider");
8013 if (!btn || btn._wired) { return; } btn._wired = true;
8014 btn.addEventListener("click", function(){
8015 var out = document.getElementById("mxchat-test-custom-provider-result");
8016 out.textContent = "' . esc_js(__('Testing...', 'mxchat')) . '";
8017 out.style.color = "#646970";
8018 var fd = new FormData();
8019 fd.append("action", "mxchat_test_custom_provider");
8020 fd.append("_wpnonce", btn.getAttribute("data-nonce"));
8021 fetch(ajaxurl, { method:"POST", credentials:"same-origin", body: fd })
8022 .then(function(r){ return r.json(); })
8023 .then(function(j){
8024 if (j && j.success) {
8025 out.textContent = "✓ " + (j.data && j.data.message ? j.data.message : "' . esc_js(__('OK', 'mxchat')) . '");
8026 out.style.color = "#00a32a";
8027 } else {
8028 out.textContent = "⚠ " + (j && j.data && j.data.message ? j.data.message : "' . esc_js(__('Failed', 'mxchat')) . '");
8029 out.style.color = "#d63638";
8030 }
8031 })
8032 .catch(function(){
8033 out.textContent = "⚠ ' . esc_js(__('Request failed', 'mxchat')) . '";
8034 out.style.color = "#d63638";
8035 });
8036 });
8037 })();</script>';
8038
8039 echo '</div>';
8040 }
8041
8042 // Voyage API Key
8043 public function voyage_api_key_callback() {
8044 $apiKey = isset($this->options['voyage_api_key']) ? esc_attr($this->options['voyage_api_key']) : '';
8045 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8046
8047 echo '<div class="api-key-wrapper">';
8048 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 . '" />';
8049 echo '<button type="button" id="toggleVoyageAPIKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
8050 echo '<p class="description">' . esc_html__('Required for Voyage AI embedding models. Get your API key from Voyage AI.', 'mxchat') . '</p>';
8051 echo '</div>';
8052 }
8053
8054 public function mxchat_loops_api_key_callback() {
8055 $loops_api_key = isset($this->options['loops_api_key']) ? esc_attr($this->options['loops_api_key']) : '';
8056 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8057
8058 echo '<div class="api-key-wrapper">';
8059 echo sprintf(
8060 '<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" />',
8061 $loops_api_key,
8062 $nonce
8063 );
8064 echo '<button type="button" id="toggleLoopsApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
8065 echo '</div>';
8066 // Cross-reference back to where the list is chosen, so the two screens point
8067 // at each other (plan-mxchat-20260802-907a63).
8068 echo '<p class="description">' . wp_kses(
8069 sprintf(
8070 /* translators: %s: link to the Loops integration tab. */
8071 __('Required for Loops email integration. Get your API key from Loops.so, then choose your mailing list under %s.', 'mxchat'),
8072 '<a href="#integrations-loops">' . esc_html__('Integrations, Loops', 'mxchat') . '</a>'
8073 ),
8074 self::mxchat_loops_pointer_allowed_html()
8075 ) . '</p>';
8076 }
8077 public function mxchat_loops_mailing_list_callback() {
8078 // Add error handling and type checking
8079 $loops_api_key = '';
8080 $selected_list = '';
8081 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8082
8083 // Safely get the API key
8084 if (isset($this->options['loops_api_key']) && is_string($this->options['loops_api_key'])) {
8085 $loops_api_key = $this->options['loops_api_key'];
8086 }
8087
8088 // Safely get the selected list
8089 if (isset($this->options['loops_mailing_list']) && is_string($this->options['loops_mailing_list'])) {
8090 $selected_list = $this->options['loops_mailing_list'];
8091 }
8092
8093 if (!empty($loops_api_key)) {
8094 $lists = $this->mxchat_fetch_loops_mailing_lists($loops_api_key);
8095 if (is_array($lists) && !empty($lists)) {
8096 echo '<div class="mxchat-field-wrapper">';
8097 echo '<select id="loops_mailing_list" name="loops_mailing_list" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
8098
8099 // Add a default "Select a list" option
8100 echo '<option value="" ' . selected($selected_list, '', false) . '>' . esc_html__('Select a list', 'mxchat') . '</option>';
8101
8102 foreach ($lists as $list) {
8103 if (is_array($list) && isset($list['id']) && isset($list['name'])) {
8104 echo sprintf(
8105 '<option value="%s" %s>%s</option>',
8106 esc_attr($list['id']),
8107 selected($selected_list, $list['id'], false),
8108 esc_html($list['name'])
8109 );
8110 }
8111 }
8112 echo '</select>';
8113 echo '</div>';
8114 echo '<p class="description">' . esc_html__('Please select a mailing list to use with Loops.', 'mxchat') . '</p>';
8115 } else {
8116 echo '<p class="description">' . wp_kses(
8117 self::mxchat_loops_api_key_pointer(
8118 /* translators: %s: link to the API Keys tab. */
8119 __('No lists found. Please check your Loops API key under %s.', 'mxchat')
8120 ),
8121 self::mxchat_loops_pointer_allowed_html()
8122 ) . '</p>';
8123 }
8124 } else {
8125 echo '<p class="description">' . wp_kses(
8126 self::mxchat_loops_api_key_pointer(
8127 /* translators: %s: link to the API Keys tab. */
8128 __('Enter your Loops API key under %s to load mailing lists.', 'mxchat')
8129 ),
8130 self::mxchat_loops_pointer_allowed_html()
8131 ) . '</p>';
8132 }
8133 }
8134
8135 /**
8136 * Build the "API Keys" pointer used by the Loops mailing-list field.
8137 *
8138 * plan-mxchat-20260802-907a63. The Loops API key field was moved to the API
8139 * Keys tab, but this field's copy still read as though the input were beside
8140 * it, so users had nowhere to go. Both live on the SAME screen
8141 * (admin.php?page=mxchat-settings) — the key is under the API Keys tab, this
8142 * dropdown under Integrations > Loops — so the pointer is an in-page tab
8143 * switch, not a cross-page link.
8144 *
8145 * A plain href="#api-keys" is all it takes: the shared admin shell
8146 * (js/admin-sidebar.js) honours section-naming hashes on load AND on
8147 * hashchange (plan 4ede16), so ordinary anchors switch tabs — the old
8148 * data-target workaround on these pointer anchors is gone.
8149 *
8150 * @param string $template Translatable string containing one %s placeholder.
8151 * @return string
8152 */
8153 private static function mxchat_loops_api_key_pointer($template) {
8154 return sprintf(
8155 $template,
8156 '<a href="#api-keys">' . esc_html__('API Keys', 'mxchat') . '</a>'
8157 );
8158 }
8159
8160 /**
8161 * Allowed HTML for the Loops pointer — anchor plus the shell's tab-switch hook.
8162 */
8163 private static function mxchat_loops_pointer_allowed_html() {
8164 return array(
8165 'a' => array(
8166 'href' => array(),
8167 'data-target' => array(),
8168 ),
8169 );
8170 }
8171 public function mxchat_triggered_phrase_response_callback() {
8172 $default_response = __('Would you like to join our mailing list? Please provide your email below.', 'mxchat');
8173 $triggered_response = isset($this->options['triggered_phrase_response'])
8174 ? $this->options['triggered_phrase_response']
8175 : $default_response;
8176 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8177
8178 echo '<div class="mxchat-field-wrapper">';
8179 echo sprintf(
8180 '<textarea id="triggered_phrase_response" name="triggered_phrase_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
8181 $nonce,
8182 esc_textarea($triggered_response)
8183 );
8184 echo '</div>';
8185 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>';
8186 }
8187
8188 public function mxchat_email_capture_response_callback() {
8189 $default_response = __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
8190 $email_capture_response = isset($this->options['email_capture_response'])
8191 ? $this->options['email_capture_response']
8192 : $default_response;
8193 $nonce = wp_create_nonce('mxchat_autosave_nonce');
8194
8195 echo '<div class="mxchat-field-wrapper">';
8196 echo sprintf(
8197 '<textarea id="email_capture_response" name="email_capture_response" rows="3" cols="50" class="mxchat-autosave-field" data-nonce="%s">%s</textarea>',
8198 $nonce,
8199 esc_textarea($email_capture_response)
8200 );
8201 echo '</div>';
8202 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>';
8203 }
8204 public function mxchat_pre_chat_message_callback() {
8205 // Load the entire 'mxchat_options' array
8206 $all_options = get_option('mxchat_options', []);
8207
8208 // Retrieve the saved message or use the default value
8209 $default_message = __('Hey there! Ask me anything!', 'mxchat');
8210 $pre_chat_message = isset($all_options['pre_chat_message']) ? $all_options['pre_chat_message'] : $default_message;
8211
8212 // Output the textarea
8213 printf(
8214 '<textarea id="pre_chat_message" name="pre_chat_message" rows="5" cols="50">%s</textarea>',
8215 esc_textarea($pre_chat_message)
8216 );
8217 }
8218
8219 // Callback for AI Instructions textarea
8220 public function system_prompt_instructions_callback() {
8221 // Retrieve the current value of the system prompt instructions
8222 $instructions = isset($this->options['system_prompt_instructions']) ? esc_textarea($this->options['system_prompt_instructions']) : '';
8223 // Render the textarea field
8224 printf(
8225 '<textarea id="system_prompt_instructions" name="system_prompt_instructions" rows="5" cols="50">%s</textarea>',
8226 $instructions
8227 );
8228 // Personalization hint
8229 echo '<p class="description" style="margin-top: 8px;">';
8230 echo esc_html__('Use {visitor_name} to personalize AI responses when lead capture is enabled.', 'mxchat') . '<br>';
8231 echo '<code style="font-size: 12px;">' . esc_html__('Example: The visitor\'s name is {visitor_name}. Address them by name.', 'mxchat') . '</code>';
8232 echo '</p>';
8233 // Date awareness (plan cfcd80): the automatic line is the default; the
8234 // placeholders are for owners who want the date at a specific spot, with
8235 // the prompt-caching cost spelled out.
8236 echo '<p class="description" style="margin-top: 8px;">';
8237 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');
8238 echo '</p>';
8239 // Sample instructions button
8240 echo '<div class="mxchat-instructions-container">';
8241 echo '<button type="button" class="mxchat-instructions-btn" id="mxchatViewSampleBtn">';
8242 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">';
8243 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
8244 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
8245 echo '</svg>';
8246 echo esc_html__('View Sample Instructions', 'mxchat');
8247 echo '</button>';
8248 echo '</div>';
8249
8250 // Add modal to WordPress admin footer instead of inline
8251 add_action('admin_footer', array($this, 'render_sample_instructions_modal'));
8252 }
8253
8254 // New method to render modal in admin footer
8255 public function render_sample_instructions_modal() {
8256 static $modal_rendered = false;
8257 if ($modal_rendered) return; // Prevent duplicate modals
8258 $modal_rendered = true;
8259
8260 echo '<div class="mxchat-instructions-modal-overlay" id="mxchatSampleModal">';
8261 echo '<div class="mxchat-instructions-modal-content">';
8262 echo '<div class="mxchat-instructions-modal-header">';
8263 echo '<h3 class="mxchat-instructions-modal-title">';
8264 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">';
8265 echo '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>';
8266 echo '<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>';
8267 echo '</svg>';
8268 echo esc_html__('Sample AI Instructions', 'mxchat');
8269 echo '</h3>';
8270 echo '<button type="button" class="mxchat-instructions-modal-close" id="mxchatModalClose">';
8271 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">';
8272 echo '<line x1="18" y1="6" x2="6" y2="18"/>';
8273 echo '<line x1="6" y1="6" x2="18" y2="18"/>';
8274 echo '</svg>';
8275 echo '</button>';
8276 echo '</div>';
8277 echo '<div class="mxchat-instructions-modal-body">';
8278 echo '<div class="mxchat-instructions-content">';
8279 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:
8280
8281 # Response Style - CRITICALLY IMPORTANT
8282 - MAXIMUM LENGTH: 1-3 short sentences per response
8283 - Ultra-concise: Get straight to the answer with no filler
8284 - No introductions like "Sure!" or "I\'d be happy to help"
8285 - No phrases like "based on my knowledge" or "according to information"
8286 - No explanatory text before giving the answer
8287 - No summaries or repetition
8288 - Hyperlink all URLs
8289 - Respond in user\'s language
8290 - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic]
8291
8292 # Knowledge Base Requirements - PREVENT HALLUCINATIONS
8293 - ONLY answer using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters
8294 - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately."
8295 - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information
8296 - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing
8297 - Better to admit insufficient information than provide inaccurate answers');
8298 echo '</div>';
8299 echo '<button type="button" class="mxchat-instructions-copy-btn" id="mxchatCopyBtn">';
8300 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">';
8301 echo '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>';
8302 echo '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>';
8303 echo '</svg>';
8304 echo esc_html__('Copy Instructions', 'mxchat');
8305 echo '</button>';
8306 echo '</div>';
8307 echo '<div class="mxchat-instructions-modal-footer">';
8308 echo '<button type="button" class="mxchat-instructions-btn-secondary" id="mxchatCloseBtn">' . esc_html__('Close', 'mxchat') . '</button>';
8309 echo '</div>';
8310 echo '</div>';
8311 echo '</div>';
8312 }
8313
8314
8315 public function mxchat_model_callback() {
8316 // Catalog refactor (plan-d14e89): single source of truth lives in
8317 // includes/class-mxchat-model-catalog.php. Dropdown groups are the
8318 // provider labels; each group maps model_id => "Label" strings.
8319 if (!class_exists('MxChat_Model_Catalog')) {
8320 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
8321 }
8322 // Retrieve the currently selected model from saved options
8323 $selected_model = isset($this->options['model']) ? esc_attr($this->options['model']) : 'gpt-5.6-sol';
8324
8325 // Pass the saved model so a provider-retired id keeps rendering as the
8326 // current selection instead of a blank select (plan e46b8f).
8327 $models = MxChat_Model_Catalog::settings_dropdown_groups($selected_model);
8328
8329 // Begin the select dropdown
8330 echo '<select id="model" name="model">';
8331
8332 // Iterate over groups of models
8333 foreach ($models as $group_label => $group_models) {
8334 echo '<optgroup label="' . esc_attr($group_label) . '">';
8335
8336 foreach ($group_models as $model_value => $model_label) {
8337 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
8338 }
8339
8340 echo '</optgroup>';
8341 }
8342
8343 echo '</select>';
8344
8345 // Add a note for OpenRouter
8346 echo '<p class="description" id="openrouter-model-note" style="display:none; color: #d63638; font-weight: 500;">';
8347 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle;"></span> ';
8348 echo esc_html__('After entering your OpenRouter API key above, click the button below to load available models.', 'mxchat');
8349 echo '</p>';
8350
8351 // API Key Status Messages (hidden by default, shown by JS based on selected model)
8352 $has_openai_key = !empty($this->options['api_key']);
8353 $has_claude_key = !empty($this->options['claude_api_key']);
8354 $has_xai_key = !empty($this->options['xai_api_key']);
8355 $has_deepseek_key = !empty($this->options['deepseek_api_key']);
8356 $has_gemini_key = !empty($this->options['gemini_api_key']);
8357 $has_openrouter_key = !empty($this->options['openrouter_api_key']);
8358
8359 // OpenAI/GPT models
8360 echo '<p class="mxchat-api-status" data-provider="openai" style="display:none;">';
8361 if ($has_openai_key) {
8362 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
8363 } else {
8364 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8365 }
8366 echo '</p>';
8367
8368 // Claude models
8369 echo '<p class="mxchat-api-status" data-provider="claude" style="display:none;">';
8370 if ($has_claude_key) {
8371 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Anthropic (Claude) detected', 'mxchat') . '</span>';
8372 } else {
8373 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Anthropic (Claude) detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8374 }
8375 echo '</p>';
8376
8377 // X.AI models
8378 echo '<p class="mxchat-api-status" data-provider="xai" style="display:none;">';
8379 if ($has_xai_key) {
8380 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for X.AI (Grok) detected', 'mxchat') . '</span>';
8381 } else {
8382 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>';
8383 }
8384 echo '</p>';
8385
8386 // DeepSeek models
8387 echo '<p class="mxchat-api-status" data-provider="deepseek" style="display:none;">';
8388 if ($has_deepseek_key) {
8389 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for DeepSeek detected', 'mxchat') . '</span>';
8390 } else {
8391 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for DeepSeek detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8392 }
8393 echo '</p>';
8394
8395 // Gemini models
8396 echo '<p class="mxchat-api-status" data-provider="gemini" style="display:none;">';
8397 if ($has_gemini_key) {
8398 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8399 } else {
8400 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8401 }
8402 echo '</p>';
8403
8404 // OpenRouter models
8405 echo '<p class="mxchat-api-status" data-provider="openrouter" style="display:none;">';
8406 if ($has_openrouter_key) {
8407 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenRouter detected', 'mxchat') . '</span>';
8408 } else {
8409 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenRouter detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8410 }
8411 echo '</p>';
8412
8413 // ADD THESE HIDDEN FIELDS RIGHT HERE:
8414 $openrouter_model = isset($this->options['openrouter_selected_model']) ? esc_attr($this->options['openrouter_selected_model']) : '';
8415 $openrouter_model_name = isset($this->options['openrouter_selected_model_name']) ? esc_attr($this->options['openrouter_selected_model_name']) : '';
8416
8417 echo '<input type="hidden" id="openrouter_selected_model" name="openrouter_selected_model" value="' . $openrouter_model . '" />';
8418 echo '<input type="hidden" id="openrouter_selected_model_name" name="openrouter_selected_model_name" value="' . $openrouter_model_name . '" />';
8419 }
8420
8421 // Update your existing callback method
8422 public function enable_streaming_toggle_callback() {
8423 // Get value from options array, default to 'on'
8424 $enabled = isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on';
8425 $checked = ($enabled === 'on') ? 'checked' : '';
8426
8427 echo '<label class="toggle-switch">';
8428 echo sprintf(
8429 '<input type="checkbox" id="enable_streaming_toggle" name="enable_streaming_toggle" value="on" %s />',
8430 esc_attr($checked)
8431 );
8432 echo '<span class="slider"></span>';
8433 echo '</label>';
8434
8435 // Test button — branded .mxch-btn with inline SVG (IDs preserved for AJAX binding)
8436 echo '<div class="mxch-streaming-test-row">';
8437 echo '<button type="button" id="mxchat-test-streaming-btn" class="mxch-btn mxch-btn-secondary">';
8438 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>';
8439 echo esc_html__('Test Streaming Compatibility', 'mxchat');
8440 echo '</button>';
8441 echo '<p id="mxchat-test-streaming-result" class="mxch-streaming-test-result"></p>';
8442 echo '</div>';
8443 }
8444
8445 // Web Search toggle callback
8446 public function enable_web_search_toggle_callback() {
8447 // Get value from options array, default to 'off'
8448 $enabled = isset($this->options['enable_web_search']) ? $this->options['enable_web_search'] : 'off';
8449 $checked = ($enabled === 'on') ? 'checked' : '';
8450
8451 // Get current model to determine if we should show/enable the toggle
8452 $current_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
8453
8454 // Models that DON'T support web search — OpenAI-docs-driven exception list.
8455 // Keep hardcoded; the catalog can't infer "supports web search" per-model, so any
8456 // future OpenAI model that lacks Responses-API web_search support is added here.
8457 $unsupported_models = array('gpt-4.1-nano');
8458
8459 // Web-search-capable chat-model allowlists, derived from the central model catalog
8460 // (class-mxchat-model-catalog.php). When a new OpenAI/Gemini chat model is added there,
8461 // the Web Search toggle picks it up automatically — no edit here.
8462 // OpenAI grounds via the Responses-API web_search tool; Gemini grounds natively via the
8463 // Google Search tool (plan 46b9ea wired the Gemini dispatch — every shipped Gemini chat
8464 // model is 2.x/3.x and grounds, matching that path's empty opt-out list, so all catalog
8465 // Gemini models are supported here).
8466 if (!class_exists('MxChat_Model_Catalog')) {
8467 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
8468 }
8469 $chat_catalog = MxChat_Model_Catalog::chat_models();
8470 $openai_models = (isset($chat_catalog['openai']['models']) && is_array($chat_catalog['openai']['models']))
8471 ? array_keys($chat_catalog['openai']['models'])
8472 : array();
8473 $gemini_models = (isset($chat_catalog['gemini']['models']) && is_array($chat_catalog['gemini']['models']))
8474 ? array_keys($chat_catalog['gemini']['models'])
8475 : array();
8476
8477 $is_capable = in_array($current_model, $openai_models) || in_array($current_model, $gemini_models);
8478 $is_supported = $is_capable && !in_array($current_model, $unsupported_models);
8479
8480 // Wrapper div with data attributes for JS to show/hide. data-openai-models is kept for
8481 // back-compat; data-gemini-models is the added second provider the JS now also honors.
8482 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;"' : '') . '>';
8483
8484 echo '<label class="toggle-switch">';
8485 echo sprintf(
8486 '<input type="checkbox" id="enable_web_search" name="enable_web_search" value="on" %s />',
8487 esc_attr($checked)
8488 );
8489 echo '<span class="slider"></span>';
8490 echo '</label>';
8491
8492 echo '</div>';
8493
8494 // Message shown when a model that can't ground (Claude, Grok, DeepSeek, OpenRouter, etc.) is selected
8495 echo '<p id="web-search-unavailable-message" class="description" style="color: #666;' . ($is_supported ? ' display:none;' : '') . '">';
8496 echo '<span class="dashicons dashicons-info" style="font-size: 16px; vertical-align: middle; margin-right: 4px;"></span>';
8497 echo esc_html__('Web search is only available for OpenAI and Gemini models.', 'mxchat');
8498 echo '</p>';
8499 }
8500
8501 // AJAX handler to fetch OpenRouter models
8502 public function fetch_openrouter_models() {
8503 check_ajax_referer('mxchat_fetch_openrouter_models', 'nonce');
8504
8505 // plan-mxchat-20260731-c63fb6 — nonce is not authorization.
8506 if (!current_user_can('manage_options')) {
8507 wp_send_json_error(array('message' => esc_html__('Unauthorized', 'mxchat')), 403);
8508 }
8509
8510 $api_key = isset($_POST['api_key']) ? sanitize_text_field($_POST['api_key']) : '';
8511
8512 if (empty($api_key)) {
8513 wp_send_json_error(array('message' => 'API key is required'));
8514 }
8515
8516 $response = wp_remote_get('https://openrouter.ai/api/v1/models', array(
8517 'headers' => array(
8518 'Authorization' => 'Bearer ' . $api_key,
8519 'Content-Type' => 'application/json',
8520 ),
8521 'timeout' => 15,
8522 ));
8523
8524 if (is_wp_error($response)) {
8525 wp_send_json_error(array('message' => $response->get_error_message()));
8526 }
8527
8528 $body = wp_remote_retrieve_body($response);
8529 $data = json_decode($body, true);
8530
8531 if (isset($data['data']) && is_array($data['data'])) {
8532 // Format the models for the frontend
8533 $models = array_map(function($model) {
8534 return array(
8535 'id' => $model['id'],
8536 'name' => $model['name'] ?? $model['id'],
8537 'description' => $model['description'] ?? '',
8538 'context_length' => $model['context_length'] ?? 0,
8539 'pricing' => array(
8540 'prompt' => $model['pricing']['prompt'] ?? 0,
8541 'completion' => $model['pricing']['completion'] ?? 0,
8542 ),
8543 );
8544 }, $data['data']);
8545
8546 wp_send_json_success(array('models' => $models));
8547 } else {
8548 wp_send_json_error(array('message' => 'Invalid response from OpenRouter'));
8549 }
8550 }
8551
8552 // Callback function for embedding model selection
8553 public function embedding_model_callback() {
8554 $models = array(
8555 esc_html__('OpenAI Embeddings', 'mxchat') => array(
8556 'text-embedding-3-small' => esc_html__('TE3 Small (1536, Efficient)', 'mxchat'),
8557 'text-embedding-ada-002' => esc_html__('Ada 2 (1536, Recommended)', 'mxchat'),
8558 'text-embedding-3-large' => esc_html__('TE3 Large (3072, Powerful)', 'mxchat'),
8559 ),
8560 esc_html__('Voyage AI Embeddings', 'mxchat') => array(
8561 'voyage-3-large' => esc_html__('Voyage-3 Large (2048, Most Capable)', 'mxchat'),
8562 ),
8563 esc_html__('Google Gemini Embeddings', 'mxchat') => array(
8564 'gemini-embedding-001' => esc_html__('Gemini Embedding (1536, Stable)', 'mxchat'),
8565 )
8566 );
8567 $selected_model = isset($this->options['embedding_model']) ? esc_attr($this->options['embedding_model']) : 'text-embedding-ada-002';
8568 // With custom-provider embeddings on, this picker is inert — the effective
8569 // model comes from the Custom Embedding Model field (plan ae02cb). Autosave
8570 // fires only on user change events, so the disabled attribute cannot cause
8571 // a missing-key save; JS keeps the state in sync with the toggle live.
8572 $custom_embeddings_on = !empty($this->options['custom_provider_for_embeddings']) && $this->options['custom_provider_for_embeddings'] === 'on';
8573 echo '<select id="embedding_model" name="embedding_model"' . ($custom_embeddings_on ? ' disabled' : '') . '>';
8574 foreach ($models as $group_label => $group_models) {
8575 echo '<optgroup label="' . esc_attr($group_label) . '">';
8576 foreach ($group_models as $model_value => $model_label) {
8577 echo '<option value="' . esc_attr($model_value) . '" ' . selected($selected_model, $model_value, false) . '>' . esc_html($model_label) . '</option>';
8578 }
8579 echo '</optgroup>';
8580 }
8581 echo '</select>';
8582 echo '<p class="mxch-field-description mxchat-embedding-custom-note" id="mxchat_embedding_custom_note"' . ($custom_embeddings_on ? '' : ' style="display:none;"') . '>';
8583 echo esc_html__('Custom provider embeddings are in use — the model is set in the Custom Embedding Model field under Custom Provider.', 'mxchat');
8584 echo '</p>';
8585
8586 // API Key Status Messages for Embedding Models
8587 $has_openai_key = !empty($this->options['api_key']);
8588 $has_voyage_key = !empty($this->options['voyage_api_key']);
8589 $has_gemini_key = !empty($this->options['gemini_api_key']);
8590
8591 // OpenAI Embeddings
8592 echo '<p class="mxchat-embedding-api-status" data-provider="openai" style="display:none;">';
8593 if ($has_openai_key) {
8594 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for OpenAI detected', 'mxchat') . '</span>';
8595 } else {
8596 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for OpenAI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8597 }
8598 echo '</p>';
8599
8600 // Voyage AI Embeddings
8601 echo '<p class="mxchat-embedding-api-status" data-provider="voyage" style="display:none;">';
8602 if ($has_voyage_key) {
8603 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Voyage AI detected', 'mxchat') . '</span>';
8604 } else {
8605 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Voyage AI detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8606 }
8607 echo '</p>';
8608
8609 // Gemini Embeddings
8610 echo '<p class="mxchat-embedding-api-status" data-provider="gemini" style="display:none;">';
8611 if ($has_gemini_key) {
8612 echo '<span style="color: #00a32a;">✓ ' . esc_html__('API key for Google Gemini detected', 'mxchat') . '</span>';
8613 } else {
8614 echo '<span style="color: #d63638;">⚠ ' . esc_html__('No API key for Google Gemini detected. Please enter API key in API Keys tab.', 'mxchat') . '</span>';
8615 }
8616 echo '</p>';
8617
8618 }
8619
8620
8621 public function mxchat_top_bar_title_callback() {
8622 // Retrieve the current value of the top bar title from saved options
8623 $top_bar_title = isset($this->options['top_bar_title']) ? esc_attr($this->options['top_bar_title']) : '';
8624
8625 // Render the input field
8626 echo '<input type="text" id="top_bar_title" name="top_bar_title" value="' . $top_bar_title . '" />';
8627 }
8628 public function mxchat_ai_agent_text_callback() {
8629 // Retrieve the current value of the AI agent text from saved options
8630 $ai_agent_text = isset($this->options['ai_agent_text']) ? esc_attr($this->options['ai_agent_text']) : '';
8631 // Render the input field
8632 echo '<input type="text" id="ai_agent_text" name="ai_agent_text" value="' . $ai_agent_text . '" />';
8633 }
8634
8635
8636 public function enable_email_block_callback() {
8637 // Load full plugin options array
8638 $all_options = get_option('mxchat_options', []);
8639
8640 // Get the value, default to 'off'
8641 $enable_email_block = isset($all_options['enable_email_block']) ? $all_options['enable_email_block'] : 'off';
8642
8643 // Check if it's 'on'
8644 $checked = ($enable_email_block === 'on') ? 'checked' : '';
8645
8646 echo '<label class="toggle-switch">';
8647 echo sprintf(
8648 '<input type="checkbox" id="enable_email_block" name="enable_email_block" value="on" %s />',
8649 esc_attr($checked)
8650 );
8651 echo '<span class="slider"></span>';
8652 echo '</label>';
8653 }
8654
8655 public function email_blocker_header_content_callback() {
8656 // Load the entire 'mxchat_options' array
8657 $all_options = get_option('mxchat_options', []);
8658
8659 // Retrieve the saved content or default to empty
8660 $content = isset($all_options['email_blocker_header_content'])
8661 ? $all_options['email_blocker_header_content']
8662 : '';
8663
8664 // Render the textarea - IMPORTANT: name should be just "email_blocker_header_content"
8665 echo '<textarea
8666 id="email_blocker_header_content"
8667 name="email_blocker_header_content"
8668 rows="5"
8669 cols="70"
8670 data-setting="email_blocker_header_content"
8671 >' . esc_textarea($content) . '</textarea>';
8672 }
8673
8674 public function email_blocker_button_text_callback() {
8675 // Load the entire 'mxchat_options' array
8676 $all_options = get_option('mxchat_options', []);
8677
8678 // Retrieve the saved button text or default to empty
8679 $button_text = isset($all_options['email_blocker_button_text'])
8680 ? $all_options['email_blocker_button_text']
8681 : '';
8682
8683 // Use esc_attr to safely render the existing text
8684 echo '<input type="text" id="email_blocker_button_text" name="email_blocker_button_text" value="' . esc_attr($button_text) . '" style="width: 300px;" />';
8685 }
8686
8687 //Enable name field callback
8688 public function enable_name_field_callback() {
8689 // Load full plugin options array
8690 $all_options = get_option('mxchat_options', []);
8691 // Get the value, default to 'off'
8692 $enable_name_field = isset($all_options['enable_name_field']) ? $all_options['enable_name_field'] : 'off';
8693 // Check if it's 'on'
8694 $checked = ($enable_name_field === 'on') ? 'checked' : '';
8695 echo '<label class="toggle-switch">';
8696 echo sprintf(
8697 '<input type="checkbox" id="enable_name_field" name="enable_name_field" value="on" %s />',
8698 esc_attr($checked)
8699 );
8700 echo '<span class="slider"></span>';
8701 echo '</label>';
8702 }
8703 //Name field placeholder callback
8704 public function name_field_placeholder_callback() {
8705 $all_options = get_option('mxchat_options', []);
8706 $placeholder = isset($all_options['name_field_placeholder'])
8707 ? $all_options['name_field_placeholder']
8708 : esc_html__('Enter your name', 'mxchat');
8709
8710 echo '<input type="text" id="name_field_placeholder" name="name_field_placeholder" value="' . esc_attr($placeholder) . '" style="width: 300px;" />';
8711 }
8712
8713 // Consent checkbox toggle (b062c4)
8714 public function enable_consent_checkbox_callback() {
8715 $all_options = get_option('mxchat_options', []);
8716 $enabled = isset($all_options['enable_consent_checkbox']) ? $all_options['enable_consent_checkbox'] : 'off';
8717 $checked = ($enabled === 'on') ? 'checked' : '';
8718 echo '<label class="toggle-switch">';
8719 echo sprintf(
8720 '<input type="checkbox" id="enable_consent_checkbox" name="enable_consent_checkbox" value="on" %s />',
8721 esc_attr($checked)
8722 );
8723 echo '<span class="slider"></span>';
8724 echo '</label>';
8725 }
8726
8727 // Consent checkbox label (b062c4). Owner content; a safe HTML subset is
8728 // allowed (anchor + inline emphasis) so the Privacy Policy can be linked.
8729 public function consent_checkbox_label_callback() {
8730 $all_options = get_option('mxchat_options', []);
8731 $label = isset($all_options['consent_checkbox_label'])
8732 ? $all_options['consent_checkbox_label']
8733 : __('I agree to the Privacy Policy.', 'mxchat');
8734
8735 echo '<textarea
8736 id="consent_checkbox_label"
8737 name="consent_checkbox_label"
8738 rows="2"
8739 cols="70"
8740 data-setting="consent_checkbox_label"
8741 >' . esc_textarea($label) . '</textarea>';
8742 }
8743
8744 // Consent required toggle (b062c4)
8745 public function consent_checkbox_required_callback() {
8746 $all_options = get_option('mxchat_options', []);
8747 $required = isset($all_options['consent_checkbox_required']) ? $all_options['consent_checkbox_required'] : 'off';
8748 $checked = ($required === 'on') ? 'checked' : '';
8749 echo '<label class="toggle-switch">';
8750 echo sprintf(
8751 '<input type="checkbox" id="consent_checkbox_required" name="consent_checkbox_required" value="on" %s />',
8752 esc_attr($checked)
8753 );
8754 echo '<span class="slider"></span>';
8755 echo '</label>';
8756 }
8757
8758 // Show the pre-chat form to logged-in visitors too (c0cfaf). Default OFF —
8759 // today the form short-circuits on is_user_logged_in(), which means a
8760 // membership or LMS site where everyone is signed in never captures a lead
8761 // and never shows the consent checkbox.
8762 public function lead_capture_logged_in_toggle_callback() {
8763 $all_options = get_option('mxchat_options', []);
8764 $enabled = isset($all_options['lead_capture_logged_in_toggle']) ? $all_options['lead_capture_logged_in_toggle'] : 'off';
8765 $checked = ($enabled === 'on') ? 'checked' : '';
8766 echo '<label class="toggle-switch">';
8767 echo sprintf(
8768 '<input type="checkbox" id="lead_capture_logged_in_toggle" name="lead_capture_logged_in_toggle" value="on" %s />',
8769 esc_attr($checked)
8770 );
8771 echo '<span class="slider"></span>';
8772 echo '</label>';
8773 }
8774
8775 // Require an email address to submit the pre-chat form (c0cfaf). Default ON,
8776 // which is today's behaviour. Turning it off lets an owner collect a name
8777 // (and consent) without demanding an address.
8778 public function lead_capture_require_email_toggle_callback() {
8779 $all_options = get_option('mxchat_options', []);
8780 $required = isset($all_options['lead_capture_require_email_toggle']) ? $all_options['lead_capture_require_email_toggle'] : 'on';
8781 $checked = ($required === 'on') ? 'checked' : '';
8782 echo '<label class="toggle-switch">';
8783 echo sprintf(
8784 '<input type="checkbox" id="lead_capture_require_email_toggle" name="lead_capture_require_email_toggle" value="on" %s />',
8785 esc_attr($checked)
8786 );
8787 echo '<span class="slider"></span>';
8788 echo '</label>';
8789 }
8790
8791
8792
8793 public function mxchat_intro_message_callback() {
8794 // Load the entire 'mxchat_options' array
8795 $all_options = get_option('mxchat_options', []);
8796 // Retrieve the saved intro message or use the default
8797 $default_message = __('Hello! How can I assist you today?', 'mxchat');
8798 $saved_message = isset($all_options['intro_message']) ? $all_options['intro_message'] : $default_message;
8799 // Escape on output (esc_textarea) — neutralizes any payload already stored before the
8800 // Wordfence Stored-XSS fix (CWE-79, plan-3f8158) and prevents </textarea> context-breakout.
8801 ?>
8802 <textarea id="intro_message" name="intro_message" rows="5" cols="50"><?php echo esc_textarea( $saved_message ); ?></textarea>
8803 <p class="description" style="margin-top: 8px;">
8804 <?php esc_html_e('Use {visitor_name} to personalize greetings when lead capture is enabled.', 'mxchat'); ?><br>
8805 <code style="font-size: 12px;"><?php esc_html_e('Example: Hello {visitor_name}! How can I help you today?', 'mxchat'); ?></code>
8806 </p>
8807 <?php
8808 }
8809
8810 public function mxchat_input_copy_callback() {
8811 // Load the entire 'mxchat_options' array
8812 $all_options = get_option('mxchat_options', []);
8813
8814 // Retrieve the saved input copy or use the default value
8815 $default_copy = __('How can I assist?', 'mxchat');
8816 $input_copy = isset($all_options['input_copy']) ? $all_options['input_copy'] : $default_copy;
8817
8818 // Output the input field with the saved value
8819 printf(
8820 '<input type="text" id="input_copy" name="input_copy" value="%s" placeholder="%s" />',
8821 esc_attr($input_copy),
8822 esc_attr__('How can I assist?', 'mxchat')
8823 );
8824 }
8825
8826
8827 public function mxchat_append_to_body_callback() {
8828 // Fetch fresh options to ensure we have the latest saved values
8829 $options = get_option('mxchat_options', array());
8830
8831 // Get value from options array, default to 'off'
8832 $append_to_body = isset($options['append_to_body']) ? $options['append_to_body'] : 'off';
8833 $checked = ($append_to_body === 'on') ? 'checked' : '';
8834
8835 // Get post type visibility settings
8836 $visibility_mode = isset($options['post_type_visibility_mode']) ? $options['post_type_visibility_mode'] : 'all';
8837 $visibility_list = isset($options['post_type_visibility_list']) ? $options['post_type_visibility_list'] : array();
8838 if (!is_array($visibility_list)) {
8839 $visibility_list = array();
8840 }
8841
8842 echo '<div class="mxchat-autosave-section">';
8843
8844 // Main toggle
8845 echo '<label class="toggle-switch">';
8846 echo sprintf(
8847 '<input type="checkbox" id="append_to_body" name="append_to_body" value="on" %s />',
8848 esc_attr($checked)
8849 );
8850 echo '<span class="slider"></span>';
8851 echo '</label>';
8852
8853 // Post Type Visibility Options (only visible when auto-display is ON)
8854 $display_style = ($append_to_body === 'on') ? '' : 'display: none;';
8855 echo '<div id="post-type-visibility-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
8856
8857 echo '<div class="mxchat-post-type-visibility-header">';
8858 echo '<h4>' . esc_html__('Post Type Visibility', 'mxchat') . '</h4>';
8859 echo '</div>';
8860
8861 // Mode selector (radio buttons)
8862 echo '<div class="mxchat-visibility-mode">';
8863
8864 echo '<label class="mxchat-radio-label">';
8865 echo '<input type="radio" name="post_type_visibility_mode" value="all" ' . checked($visibility_mode, 'all', false) . ' />';
8866 echo '<span>' . esc_html__('Show on all post types', 'mxchat') . '</span>';
8867 echo '</label>';
8868
8869 echo '<label class="mxchat-radio-label">';
8870 echo '<input type="radio" name="post_type_visibility_mode" value="include" ' . checked($visibility_mode, 'include', false) . ' />';
8871 echo '<span>' . esc_html__('Only show on selected post types', 'mxchat') . '</span>';
8872 echo '</label>';
8873
8874 echo '<label class="mxchat-radio-label">';
8875 echo '<input type="radio" name="post_type_visibility_mode" value="exclude" ' . checked($visibility_mode, 'exclude', false) . ' />';
8876 echo '<span>' . esc_html__('Hide on selected post types', 'mxchat') . '</span>';
8877 echo '</label>';
8878
8879 echo '</div>';
8880
8881 // Post type checkboxes (only visible when mode is include or exclude)
8882 $list_display = ($visibility_mode !== 'all') ? '' : 'display: none;';
8883 echo '<div id="post-type-list" class="mxchat-post-type-list" style="' . esc_attr($list_display) . '">';
8884
8885 // Get all public post types
8886 $post_types = get_post_types(array('public' => true), 'objects');
8887
8888 foreach ($post_types as $post_type) {
8889 // Skip attachments
8890 if ($post_type->name === 'attachment') {
8891 continue;
8892 }
8893
8894 $is_checked = in_array($post_type->name, $visibility_list) ? 'checked' : '';
8895
8896 echo '<label class="mxchat-checkbox-label">';
8897 echo '<input type="checkbox" name="post_type_visibility_list[]" value="' . esc_attr($post_type->name) . '" ' . $is_checked . ' />';
8898 echo '<span>' . esc_html($post_type->label) . '</span>';
8899 echo '</label>';
8900 }
8901
8902 echo '</div>'; // End post-type-list
8903 echo '</div>'; // End post-type-visibility-options
8904 echo '</div>'; // End mxchat-autosave-section
8905 }
8906
8907 public function mxchat_contextual_awareness_callback() {
8908 // Get value from options array, default to 'off'
8909 $contextual_awareness = isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off';
8910 $checked = ($contextual_awareness === 'on') ? 'checked' : '';
8911 echo '<label class="toggle-switch">';
8912 echo sprintf(
8913 '<input type="checkbox" id="contextual_awareness_toggle" name="contextual_awareness_toggle" value="on" %s />',
8914 esc_attr($checked)
8915 );
8916 echo '<span class="slider"></span>';
8917 echo '</label>';
8918 }
8919
8920 public function mxchat_citation_links_toggle_callback() {
8921 // Get value from options array, default to 'on' (enabled by default)
8922 $citation_links = isset($this->options['citation_links_toggle']) ? $this->options['citation_links_toggle'] : 'on';
8923 $checked = ($citation_links === 'on') ? 'checked' : '';
8924 echo '<label class="toggle-switch">';
8925 echo sprintf(
8926 '<input type="checkbox" id="citation_links_toggle" name="citation_links_toggle" value="on" %s />',
8927 esc_attr($checked)
8928 );
8929 echo '<span class="slider"></span>';
8930 echo '</label>';
8931 }
8932
8933 /**
8934 * "Strip unapproved links" toggle (plan 58f8b4). Until the site saves an
8935 * explicit value the rendered state is the install-stamp default — on for
8936 * installs born at 3.2.20+, off for upgrades — so what the admin sees here
8937 * always matches what the response URL guard is actually doing.
8938 */
8939 public function mxchat_strip_unapproved_links_toggle_callback() {
8940 if (isset($this->options['strip_unapproved_links_toggle'])) {
8941 $strip_links = $this->options['strip_unapproved_links_toggle'];
8942 } else {
8943 $strip_links = function_exists('mxchat_strip_unapproved_links_default')
8944 ? mxchat_strip_unapproved_links_default()
8945 : 'off';
8946 }
8947 $checked = ($strip_links === 'on') ? 'checked' : '';
8948 echo '<label class="toggle-switch">';
8949 echo sprintf(
8950 '<input type="checkbox" id="strip_unapproved_links_toggle" name="strip_unapproved_links_toggle" value="on" %s />',
8951 esc_attr($checked)
8952 );
8953 echo '<span class="slider"></span>';
8954 echo '</label>';
8955 }
8956
8957 /**
8958 * Toggle for the end-of-session satisfaction rating prompt (plan-a5b006).
8959 * Default ON. The widget reads this through the localized object; the
8960 * mxchat_satisfaction_rating_enabled filter still lets developers force
8961 * the value site-wide.
8962 *
8963 * The 5 customization fields (idle/question/thanks/placeholder/saved) are
8964 * rendered inline here inside a single wrapper div whose initial display
8965 * is set server-side from the toggle value (plan-29caac). Mirrors the
8966 * auto-display chatbot pattern at mxchat_append_to_body_callback — no
8967 * DOMContentLoaded race because rows exist as direct children of this
8968 * callback's output, and the wrapper's display: none is inline at render
8969 * time so refresh shows the correct state with no flash.
8970 */
8971 public function mxchat_satisfaction_rating_toggle_callback() {
8972 $options = $this->options;
8973 $value = isset($options['satisfaction_rating_enabled']) ? $options['satisfaction_rating_enabled'] : 'off';
8974 $checked = ($value === 'on') ? 'checked' : '';
8975
8976 $idle = isset($options['satisfaction_rating_idle_seconds']) ? intval($options['satisfaction_rating_idle_seconds']) : 60;
8977 $idle = max(5, min(600, $idle));
8978 $question = isset($options['satisfaction_rating_question']) ? $options['satisfaction_rating_question'] : '';
8979 $thanks = isset($options['satisfaction_rating_thanks']) ? $options['satisfaction_rating_thanks'] : '';
8980 $placeholder = isset($options['satisfaction_rating_placeholder']) ? $options['satisfaction_rating_placeholder'] : '';
8981 $saved = isset($options['satisfaction_rating_saved']) ? $options['satisfaction_rating_saved'] : '';
8982
8983 echo '<div class="mxchat-autosave-section">';
8984
8985 echo '<label class="toggle-switch">';
8986 echo sprintf(
8987 '<input type="checkbox" id="satisfaction_rating_enabled" name="satisfaction_rating_enabled" value="on" %s />',
8988 esc_attr($checked)
8989 );
8990 echo '<span class="slider"></span>';
8991 echo '</label>';
8992
8993 ?>
8994 <style>
8995 .mxchat-sub-options-field { margin: 12px 0; }
8996 .mxchat-sub-options-field label { display: inline-block; margin-bottom: 4px; }
8997 #satisfaction-rating-sub-options h4 { margin: 16px 0 8px; }
8998 </style>
8999 <?php
9000
9001 $display_style = ($value === 'on') ? '' : 'display: none;';
9002 echo '<div id="satisfaction-rating-sub-options" class="mxchat-sub-options" style="' . esc_attr($display_style) . '">';
9003
9004 echo '<h4>' . esc_html__('Customize the prompt (optional)', 'mxchat') . '</h4>';
9005
9006 echo '<div class="mxchat-sub-options-field">';
9007 echo '<label for="satisfaction_rating_idle_seconds"><strong>' . esc_html__('Idle Timeout', 'mxchat') . '</strong></label><br />';
9008 printf(
9009 '<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>',
9010 (int) $idle,
9011 esc_html__('seconds of user inactivity before the prompt appears (5-600)', 'mxchat')
9012 );
9013 echo '</div>';
9014
9015 echo '<div class="mxchat-sub-options-field">';
9016 echo '<label for="satisfaction_rating_question"><strong>' . esc_html__('Prompt Question', 'mxchat') . '</strong></label><br />';
9017 printf(
9018 '<input type="text" id="satisfaction_rating_question" name="satisfaction_rating_question" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
9019 esc_attr($question),
9020 esc_attr__('Was this helpful?', 'mxchat')
9021 );
9022 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown above the thumbs up/down.', 'mxchat') . '</p>';
9023 echo '</div>';
9024
9025 echo '<div class="mxchat-sub-options-field">';
9026 echo '<label for="satisfaction_rating_thanks"><strong>' . esc_html__('Thank-You Message', 'mxchat') . '</strong></label><br />';
9027 printf(
9028 '<input type="text" id="satisfaction_rating_thanks" name="satisfaction_rating_thanks" value="%s" maxlength="300" class="regular-text" placeholder="%s" />',
9029 esc_attr($thanks),
9030 esc_attr__('Thanks! Anything we should improve? (optional)', 'mxchat')
9031 );
9032 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the user clicks a thumb.', 'mxchat') . '</p>';
9033 echo '</div>';
9034
9035 echo '<div class="mxchat-sub-options-field">';
9036 echo '<label for="satisfaction_rating_placeholder"><strong>' . esc_html__('Feedback Placeholder', 'mxchat') . '</strong></label><br />';
9037 printf(
9038 '<input type="text" id="satisfaction_rating_placeholder" name="satisfaction_rating_placeholder" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
9039 esc_attr($placeholder),
9040 esc_attr__('Tell us what could be better…', 'mxchat')
9041 );
9042 echo '<p class="description">' . esc_html__('Leave blank for the default. Placeholder text inside the feedback textarea.', 'mxchat') . '</p>';
9043 echo '</div>';
9044
9045 echo '<div class="mxchat-sub-options-field">';
9046 echo '<label for="satisfaction_rating_saved"><strong>' . esc_html__('Saved Confirmation', 'mxchat') . '</strong></label><br />';
9047 printf(
9048 '<input type="text" id="satisfaction_rating_saved" name="satisfaction_rating_saved" value="%s" maxlength="200" class="regular-text" placeholder="%s" />',
9049 esc_attr($saved),
9050 esc_attr__('Thanks for the feedback.', 'mxchat')
9051 );
9052 echo '<p class="description">' . esc_html__('Leave blank for the default. Shown after the feedback is sent.', 'mxchat') . '</p>';
9053 echo '</div>';
9054
9055 echo '</div>'; // #satisfaction-rating-sub-options
9056 echo '</div>'; // .mxchat-autosave-section
9057
9058 ?>
9059 <script>
9060 (function() {
9061 document.addEventListener('DOMContentLoaded', function() {
9062 var toggle = document.getElementById('satisfaction_rating_enabled');
9063 var subOptions = document.getElementById('satisfaction-rating-sub-options');
9064 if (!toggle || !subOptions) return;
9065 toggle.addEventListener('change', function() {
9066 subOptions.style.display = toggle.checked ? '' : 'none';
9067 });
9068 });
9069 })();
9070 </script>
9071 <?php
9072 }
9073
9074 public function mxchat_privacy_toggle_callback() {
9075 // Load from mxchat_options array
9076 $options = get_option('mxchat_options', []);
9077
9078 // Get privacy toggle value with fallback
9079 $privacy_toggle = isset($options['privacy_toggle']) ? $options['privacy_toggle'] : 'off';
9080 $checked = ($privacy_toggle === 'on') ? 'checked' : '';
9081
9082 // Get privacy text with fallback
9083 $privacy_text = isset($options['privacy_text'])
9084 ? $options['privacy_text']
9085 : __('By chatting, you agree to our <a href="https://example.com/privacy-policy" target="_blank">privacy policy</a>.', 'mxchat');
9086
9087 // Output the toggle switch
9088 echo '<label class="toggle-switch">';
9089 echo sprintf(
9090 '<input type="checkbox" id="privacy_toggle" name="privacy_toggle" value="on" %s />',
9091 esc_attr($checked)
9092 );
9093 echo '<span class="slider"></span>';
9094 echo '</label>';
9095
9096 // Output the custom text input field
9097 echo sprintf(
9098 '<textarea id="privacy_text" name="privacy_text" rows="5" cols="50" class="regular-text">%s</textarea>',
9099 esc_textarea($privacy_text)
9100 );
9101 }
9102
9103
9104 public function mxchat_complianz_toggle_callback() {
9105 // Load from mxchat_options array
9106 $options = get_option('mxchat_options', []);
9107
9108 // Get complianz toggle value with fallback
9109 $complianz_toggle = isset($options['complianz_toggle']) ? $options['complianz_toggle'] : 'off';
9110 $checked = ($complianz_toggle === 'on') ? 'checked' : '';
9111
9112 // Output the toggle switch
9113 echo '<label class="toggle-switch">';
9114 echo sprintf(
9115 '<input type="checkbox" id="complianz_toggle" name="complianz_toggle" value="on" %s />',
9116 esc_attr($checked)
9117 );
9118 echo '<span class="slider"></span>';
9119 echo '</label>';
9120 }
9121
9122 public function mxchat_link_target_toggle_callback() {
9123 // Load from mxchat_options array
9124 $options = get_option('mxchat_options', []);
9125
9126 // Get link target toggle value with fallback
9127 $link_target_toggle = isset($options['link_target_toggle']) ? $options['link_target_toggle'] : 'off';
9128 $checked = ($link_target_toggle === 'on') ? 'checked' : '';
9129
9130 // Output the toggle switch
9131 echo '<label class="toggle-switch">';
9132 echo sprintf(
9133 '<input type="checkbox" id="link_target_toggle" name="link_target_toggle" value="on" %s />',
9134 esc_attr($checked)
9135 );
9136 echo '<span class="slider"></span>';
9137 echo '</label>';
9138 }
9139
9140 public function mxchat_chat_persistence_toggle_callback() {
9141 // Load from mxchat_options array
9142 $options = get_option('mxchat_options', []);
9143
9144 // Get chat persistence toggle value with fallback
9145 $chat_persistence_toggle = isset($options['chat_persistence_toggle']) ? $options['chat_persistence_toggle'] : 'off';
9146 $checked = ($chat_persistence_toggle === 'on') ? 'checked' : '';
9147
9148 // Output the toggle switch
9149 echo '<label class="toggle-switch">';
9150 echo sprintf(
9151 '<input type="checkbox" id="chat_persistence_toggle" name="chat_persistence_toggle" value="on" %s />',
9152 esc_attr($checked)
9153 );
9154 echo '<span class="slider"></span>';
9155 echo '</label>';
9156 }
9157
9158 public function mxchat_print_button_toggle_callback() {
9159 // Load from mxchat_options array
9160 $options = get_option('mxchat_options', []);
9161
9162 // Default ON — the option was previously unexposed and the button always showed.
9163 $print_button_enabled = isset($options['print_button_enabled']) ? $options['print_button_enabled'] : 'on';
9164 $checked = ($print_button_enabled === 'on') ? 'checked' : '';
9165
9166 // Output the toggle switch
9167 echo '<label class="toggle-switch">';
9168 echo sprintf(
9169 '<input type="checkbox" id="print_button_enabled" name="print_button_enabled" value="on" %s />',
9170 esc_attr($checked)
9171 );
9172 echo '<span class="slider"></span>';
9173 echo '</label>';
9174 }
9175
9176 /**
9177 * Editor Assistant toggle (plan-8cb0cb). Standalone option, NOT in mxchat_options
9178 * (bypasses the mxchat_sanitize strip-trap + autosave normalization). Default OFF.
9179 * Saved by the mxchat_editor_assistant_enabled case in class-ajax-handler.php.
9180 * Renders on Content → Settings (moved there from Settings → Behavior, plan-f7df40).
9181 */
9182 public function mxchat_editor_assistant_toggle_callback() {
9183 $enabled = get_option('mxchat_editor_assistant_enabled', 'off');
9184 $checked = ($enabled === 'on') ? 'checked' : '';
9185
9186 echo '<label class="toggle-switch">';
9187 echo sprintf(
9188 '<input type="checkbox" id="mxchat_editor_assistant_enabled" name="mxchat_editor_assistant_enabled" value="on" %s />',
9189 esc_attr($checked)
9190 );
9191 echo '<span class="slider"></span>';
9192 echo '</label>';
9193 }
9194
9195 /**
9196 * Hybrid keyword boost toggle (plan-38ffa1; rebuilt on the house toggle pattern
9197 * in plan-64d34f — the hand-rolled .mxch-toggle markup matched neither selector
9198 * the autosave JS uses to place its save confirmation, so the control looked
9199 * dead). Standalone option, NOT in mxchat_options. Default OFF. Saved by the
9200 * mxchat_hybrid_keyword_toggle case in class-ajax-handler.php, which also runs
9201 * capability detection on enable.
9202 */
9203 public function mxchat_hybrid_keyword_toggle_callback() {
9204 $enabled = get_option('mxchat_hybrid_keyword_toggle', 'off');
9205 $checked = ($enabled === 'on') ? 'checked' : '';
9206
9207 echo '<label class="toggle-switch">';
9208 echo sprintf(
9209 '<input type="checkbox" id="mxchat_hybrid_keyword_toggle" name="mxchat_hybrid_keyword_toggle" value="on" %s />',
9210 esc_attr($checked)
9211 );
9212 echo '<span class="slider"></span>';
9213 echo '</label>';
9214 }
9215
9216 /**
9217 * Smart asset loading toggle (plan-915355; rebuilt on the house toggle pattern
9218 * in plan-64d34f, same dead-save-feedback defect as the hybrid keyword toggle).
9219 * Standalone option, NOT in mxchat_options. Default OFF. Saved by the
9220 * mxchat_smart_asset_loading case in class-ajax-handler.php.
9221 */
9222 public function mxchat_smart_asset_loading_toggle_callback() {
9223 $enabled = get_option('mxchat_smart_asset_loading', 'off');
9224 $checked = ($enabled === 'on') ? 'checked' : '';
9225
9226 echo '<label class="toggle-switch">';
9227 echo sprintf(
9228 '<input type="checkbox" id="mxchat_smart_asset_loading" name="mxchat_smart_asset_loading" value="on" %s />',
9229 esc_attr($checked)
9230 );
9231 echo '<span class="slider"></span>';
9232 echo '</label>';
9233 }
9234
9235 public function mxchat_reset_chat_toggle_callback() {
9236 // Load from mxchat_options array. plan ac2e81 — default OFF (new, opt-in).
9237 $options = get_option('mxchat_options', []);
9238 $reset_chat_enabled = isset($options['reset_chat_enabled']) ? $options['reset_chat_enabled'] : 'off';
9239 $checked = ($reset_chat_enabled === 'on') ? 'checked' : '';
9240
9241 echo '<label class="toggle-switch">';
9242 echo sprintf(
9243 '<input type="checkbox" id="reset_chat_enabled" name="reset_chat_enabled" value="on" %s />',
9244 esc_attr($checked)
9245 );
9246 echo '<span class="slider"></span>';
9247 echo '</label>';
9248 }
9249
9250 public function mxchat_reset_chat_label_callback() {
9251 // Editable label for the "Start new chat" menu item. plan ac2e81.
9252 $options = get_option('mxchat_options', []);
9253 $reset_chat_label = isset($options['reset_chat_label']) ? $options['reset_chat_label'] : '';
9254
9255 printf(
9256 '<input type="text" id="reset_chat_label" name="reset_chat_label" value="%s" placeholder="%s" class="regular-text" />',
9257 esc_attr($reset_chat_label),
9258 esc_attr__('Start new chat', 'mxchat')
9259 );
9260 }
9261
9262 public function mxchat_popular_question_1_callback() {
9263 // Load the full plugin options array
9264 $all_options = get_option('mxchat_options', []);
9265
9266 // Retrieve the specific option for popular_question_1
9267 $popular_question_1 = isset($all_options['popular_question_1']) ? $all_options['popular_question_1'] : '';
9268
9269 // Render the input field
9270 printf(
9271 '<input type="text" id="popular_question_1" name="popular_question_1" value="%s" placeholder="%s" class="regular-text" />',
9272 esc_attr($popular_question_1),
9273 esc_attr__('Enter Quick Question 1', 'mxchat')
9274 );
9275 }
9276
9277
9278 public function mxchat_popular_question_2_callback() {
9279 // Load the full plugin options array
9280 $all_options = get_option('mxchat_options', []);
9281
9282 // Retrieve the specific option for popular_question_2
9283 $popular_question_2 = isset($all_options['popular_question_2']) ? $all_options['popular_question_2'] : '';
9284
9285 // Render the input field
9286 printf(
9287 '<input type="text" id="popular_question_2" name="popular_question_2" value="%s" placeholder="%s" class="regular-text" />',
9288 esc_attr($popular_question_2),
9289 esc_attr__('Enter Quick Question 2', 'mxchat')
9290 );
9291 }
9292
9293
9294 public function mxchat_popular_question_3_callback() {
9295 // Load the full plugin options array
9296 $all_options = get_option('mxchat_options', []);
9297
9298 // Retrieve the specific option for popular_question_3
9299 $popular_question_3 = isset($all_options['popular_question_3']) ? $all_options['popular_question_3'] : '';
9300
9301 // Render the input field
9302 printf(
9303 '<input type="text" id="popular_question_3" name="popular_question_3" value="%s" placeholder="%s" class="regular-text" />',
9304 esc_attr($popular_question_3),
9305 esc_attr(__('Enter Quick Question 3', 'mxchat'))
9306 );
9307 }
9308
9309 public function mxchat_additional_popular_questions_callback() {
9310 $options = get_option('mxchat_options', []);
9311 $additional_questions = isset($options['additional_popular_questions'])
9312 ? $options['additional_popular_questions']
9313 : get_option('additional_popular_questions', array());
9314
9315 echo '<div id="mxchat-additional-questions-container">';
9316 if (!empty($additional_questions)) {
9317 foreach ($additional_questions as $index => $question) {
9318 printf(
9319 '<div class="mxchat-question-row">
9320 <input type="text" name="additional_popular_questions[]"
9321 value="%s"
9322 placeholder="%s"
9323 class="regular-text mxchat-question-input"
9324 data-question-index="%d" />
9325 <button type="button" class="button mxchat-remove-question"
9326 aria-label="%s">%s</button>
9327 </div>',
9328 esc_attr($question),
9329 esc_attr(sprintf(__('Enter Additional Quick Question %d', 'mxchat'), $index + 4)),
9330 $index,
9331 esc_attr(__('Remove question', 'mxchat')),
9332 esc_html__('Remove', 'mxchat')
9333 );
9334 }
9335 } else {
9336 printf(
9337 '<div class="mxchat-question-row">
9338 <input type="text" name="additional_popular_questions[]"
9339 value=""
9340 placeholder="%s"
9341 class="regular-text mxchat-question-input"
9342 data-question-index="0" />
9343 <button type="button" class="button mxchat-remove-question"
9344 aria-label="%s">%s</button>
9345 </div>',
9346 esc_attr(__('Enter Additional Quick Question 4', 'mxchat')),
9347 esc_attr(__('Remove question', 'mxchat')),
9348 esc_html__('Remove', 'mxchat')
9349 );
9350 }
9351 echo '</div>';
9352 printf(
9353 '<button type="button" class="button mxchat-add-question" aria-label="%s">%s</button>',
9354 esc_attr(__('Add question', 'mxchat')),
9355 esc_html__('Add Question', 'mxchat')
9356 );
9357 }
9358
9359 public function mxchat_brave_api_key_callback() {
9360 $brave_api_key = isset($this->options['brave_api_key']) ? esc_attr($this->options['brave_api_key']) : '';
9361 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9362
9363 echo '<div class="api-key-wrapper">';
9364 echo sprintf(
9365 '<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" />',
9366 $brave_api_key,
9367 $nonce
9368 );
9369 echo '<button type="button" id="toggleBraveApiKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9370 echo '</div>';
9371 echo '<p class="description">' . __('Required for Brave Search integration. Get your API key from Brave Search API.', 'mxchat') . '</p>';
9372 }
9373
9374 public function mxchat_brave_image_count_callback() {
9375 $brave_image_count = isset($this->options['brave_image_count'])
9376 ? intval($this->options['brave_image_count'])
9377 : 4;
9378 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9379
9380 echo '<div class="mxchat-field-wrapper">';
9381 echo sprintf(
9382 '<input type="number" id="brave_image_count" name="brave_image_count"
9383 value="%d" min="1" max="6" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9384 $brave_image_count,
9385 $nonce
9386 );
9387 echo '</div>';
9388 echo '<p class="description">' . __('Select the number of images to return (1-6).', 'mxchat') . '</p>';
9389 }
9390
9391 public function mxchat_brave_safe_search_callback() {
9392 $brave_safe_search = isset($this->options['brave_safe_search'])
9393 ? esc_attr($this->options['brave_safe_search'])
9394 : 'strict';
9395 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9396
9397 echo '<div class="mxchat-field-wrapper">';
9398 echo '<select id="brave_safe_search" name="brave_safe_search" class="mxchat-autosave-field" data-nonce="' . $nonce . '">';
9399 echo sprintf(
9400 '<option value="strict" %s>%s</option>',
9401 selected($brave_safe_search, 'strict', false),
9402 __('Strict', 'mxchat')
9403 );
9404 echo sprintf(
9405 '<option value="off" %s>%s</option>',
9406 selected($brave_safe_search, 'off', false),
9407 __('Off', 'mxchat')
9408 );
9409 echo '</select>';
9410 echo '</div>';
9411 echo '<p class="description">' .
9412 esc_html__('Set the Safe Search level for image searches. Brave Search only supports "Strict" and "Off" options.', 'mxchat') .
9413 '</p>';
9414 }
9415
9416 public function mxchat_brave_news_count_callback() {
9417 $brave_news_count = isset($this->options['brave_news_count'])
9418 ? intval($this->options['brave_news_count'])
9419 : 3;
9420 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9421
9422 echo '<div class="mxchat-field-wrapper">';
9423 echo sprintf(
9424 '<input type="number" id="brave_news_count" name="brave_news_count"
9425 value="%d" min="1" max="10" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9426 $brave_news_count,
9427 $nonce
9428 );
9429 echo '</div>';
9430 echo '<p class="description">' . esc_html__('Select the number of news articles to retrieve (1-10).', 'mxchat') . '</p>';
9431 }
9432
9433 public function mxchat_brave_country_callback() {
9434 $brave_country = isset($this->options['brave_country'])
9435 ? esc_attr($this->options['brave_country'])
9436 : 'us';
9437 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9438
9439 echo '<div class="mxchat-field-wrapper">';
9440 echo sprintf(
9441 '<input type="text" id="brave_country" name="brave_country"
9442 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9443 $brave_country,
9444 $nonce
9445 );
9446 echo '</div>';
9447 echo '<p class="description">' . esc_html__('Enter the country code (e.g., "us" for United States).', 'mxchat') . '</p>';
9448 }
9449
9450 public function mxchat_brave_language_callback() {
9451 $brave_language = isset($this->options['brave_language'])
9452 ? esc_attr($this->options['brave_language'])
9453 : 'en';
9454 $nonce = wp_create_nonce('mxchat_autosave_nonce');
9455
9456 echo '<div class="mxchat-field-wrapper">';
9457 echo sprintf(
9458 '<input type="text" id="brave_language" name="brave_language"
9459 value="%s" maxlength="2" class="small-text mxchat-autosave-field" data-nonce="%s" />',
9460 $brave_language,
9461 $nonce
9462 );
9463 echo '</div>';
9464 echo '<p class="description">' . esc_html__('Enter the language code (e.g., "en" for English).', 'mxchat') . '</p>';
9465 }
9466
9467
9468
9469
9470
9471 // Section Callback
9472 public function mxchat_pdf_intent_section_callback() {
9473 echo '<p>' . esc_html__('Configure the intent settings for the Chat with PDF feature.', 'mxchat') . '</p>';
9474 }
9475
9476 public function mxchat_chat_toolbar_toggle_callback() {
9477 // Get chat toolbar toggle value with fallback
9478 $chat_toolbar_toggle = isset($this->options['chat_toolbar_toggle']) ? $this->options['chat_toolbar_toggle'] : 'off';
9479 $checked = ($chat_toolbar_toggle === 'on') ? 'checked' : '';
9480
9481 // Output the toggle switch
9482 echo '<label class="toggle-switch">';
9483 echo sprintf(
9484 '<input type="checkbox" id="chat_toolbar_toggle" name="chat_toolbar_toggle" value="on" %s />',
9485 esc_attr($checked)
9486 );
9487 echo '<span class="slider"></span>';
9488 echo '</label>';
9489
9490 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>';
9491 }
9492
9493 /**
9494 * Callback for PDF upload button toggle setting
9495 */
9496 public function mxchat_show_pdf_upload_button_callback() {
9497 // Get toggle value with fallback
9498 $show_pdf_button = isset($this->options['show_pdf_upload_button']) ? $this->options['show_pdf_upload_button'] : 'on';
9499 $checked = ($show_pdf_button === 'on') ? 'checked' : '';
9500
9501 // Output the toggle switch
9502 echo '<label class="toggle-switch">';
9503 echo sprintf(
9504 '<input type="checkbox" id="show_pdf_upload_button" name="show_pdf_upload_button" value="on" %s />',
9505 esc_attr($checked)
9506 );
9507 echo '<span class="slider"></span>';
9508 echo '</label>';
9509
9510 echo '<p class="description">' . esc_html__('Enable to show the PDF upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
9511 }
9512
9513 /**
9514 * Callback for Word upload button toggle setting
9515 */
9516 public function mxchat_show_word_upload_button_callback() {
9517 // Get toggle value with fallback
9518 $show_word_button = isset($this->options['show_word_upload_button']) ? $this->options['show_word_upload_button'] : 'on';
9519 $checked = ($show_word_button === 'on') ? 'checked' : '';
9520
9521 // Output the toggle switch
9522 echo '<label class="toggle-switch">';
9523 echo sprintf(
9524 '<input type="checkbox" id="show_word_upload_button" name="show_word_upload_button" value="on" %s />',
9525 esc_attr($checked)
9526 );
9527 echo '<span class="slider"></span>';
9528 echo '</label>';
9529
9530 echo '<p class="description">' . esc_html__('Enable to show the Word document upload button in the chatbot toolbar. Disable to hide it.', 'mxchat') . '</p>';
9531 }
9532
9533 public function mxchat_pdf_intent_trigger_text_callback() {
9534 $default_text = __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat');
9535
9536 echo sprintf(
9537 '<textarea id="pdf_intent_trigger_text"
9538 name="pdf_intent_trigger_text"
9539 rows="3"
9540 cols="50"
9541 placeholder="%s">%s</textarea>',
9542 esc_attr__('Enter trigger text', 'mxchat'),
9543 isset($this->options['pdf_intent_trigger_text'])
9544 ? esc_textarea($this->options['pdf_intent_trigger_text'])
9545 : esc_textarea($default_text)
9546 );
9547 echo '<p class="description">' . esc_html__('Text displayed when the intent is triggered.', 'mxchat') . '</p>';
9548 }
9549
9550 public function mxchat_pdf_intent_success_text_callback() {
9551 $default_text = __("I've processed the PDF. What questions do you have about it?", 'mxchat');
9552
9553 echo sprintf(
9554 '<textarea id="pdf_intent_success_text"
9555 name="pdf_intent_success_text"
9556 rows="3"
9557 cols="50"
9558 placeholder="%s">%s</textarea>',
9559 esc_attr__('Enter success text', 'mxchat'),
9560 isset($this->options['pdf_intent_success_text'])
9561 ? esc_textarea($this->options['pdf_intent_success_text'])
9562 : esc_textarea($default_text)
9563 );
9564 echo '<p class="description">' . esc_html__('Text displayed when the intent is successful.', 'mxchat') . '</p>';
9565 }
9566
9567 public function mxchat_pdf_intent_error_text_callback() {
9568 $default_text = __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
9569
9570 echo sprintf(
9571 '<textarea id="pdf_intent_error_text"
9572 name="pdf_intent_error_text"
9573 rows="3"
9574 cols="50"
9575 placeholder="%s">%s</textarea>',
9576 esc_attr__('Enter error text', 'mxchat'),
9577 isset($this->options['pdf_intent_error_text'])
9578 ? esc_textarea($this->options['pdf_intent_error_text'])
9579 : esc_textarea($default_text)
9580 );
9581 echo '<p class="description">' . esc_html__('Text displayed when an error occurs during the intent.', 'mxchat') . '</p>';
9582 }
9583
9584 public function mxchat_pdf_max_pages_callback() {
9585 $max_pages = isset($this->options['pdf_max_pages']) ? intval($this->options['pdf_max_pages']) : 69;
9586
9587 echo sprintf(
9588 '<input type="range"
9589 id="pdf_max_pages"
9590 name="pdf_max_pages"
9591 min="1"
9592 max="69"
9593 value="%d"
9594 class="range-slider" />',
9595 esc_attr($max_pages)
9596 );
9597 echo '<span id="pdf_max_pages_output">' . esc_html($max_pages) . '</span>';
9598 echo '<p class="description">' . esc_html__('Set the maximum number of document pages users can upload for processing. (1-69 pages)', 'mxchat') . '</p>';
9599 }
9600
9601 public function mxchat_live_agent_status_callback() {
9602 // Always get fresh options instead of using cached $this->options
9603 $fresh_options = get_option('mxchat_options');
9604 $status = isset($fresh_options['live_agent_status']) ? $fresh_options['live_agent_status'] : 'off';
9605
9606 echo '<label class="toggle-switch">';
9607 echo sprintf(
9608 '<input type="checkbox" id="live_agent_status" name="live_agent_status" value="on" %s />',
9609 checked($status, 'on', false)
9610 );
9611 echo '<span class="slider"></span>';
9612 echo '</label>';
9613 echo '<label for="live_agent_status" class="mxchat-status-label">';
9614 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9615 echo '</label>';
9616 }
9617
9618 /**
9619 * Availability-schedule editor (plans 8ccaa2 + 99d7a4).
9620 *
9621 * ONE callback, parameterized by channel ('slack' | 'telegram' via the
9622 * add_settings_field $args) — the markup and CSS are shared, only the bound
9623 * option and the helper text differ. Each channel's editor renders under its
9624 * own Integrations tab and governs ONLY that channel's handoff. While the
9625 * master toggle is off the day grid is inert and handoff availability stays
9626 * governed solely by that channel's manual status toggle.
9627 *
9628 * The day inputs carry NO name attribute and are marked .mxchat-la-field so the
9629 * generic autosave skips them; the editor JS folds them into the channel's
9630 * hidden live_agent_schedule_<channel> input and fires one change, reusing the
9631 * existing autosave transport rather than growing a second one. All hooks the
9632 * JS needs are CLASSES scoped inside .mxchat-la-schedule, never ids — the
9633 * markup exists twice on the page.
9634 */
9635 public function mxchat_live_agent_schedule_callback($args = array()) {
9636 if (!class_exists('MxChat_Live_Agent_Schedule')) {
9637 return;
9638 }
9639 $channel = (isset($args['channel']) && in_array($args['channel'], MxChat_Live_Agent_Schedule::channels(), true))
9640 ? $args['channel']
9641 : 'slack';
9642 $field = 'live_agent_schedule_' . $channel;
9643 $sched = MxChat_Live_Agent_Schedule::get($channel);
9644 $labels = MxChat_Live_Agent_Schedule::day_labels();
9645 $tz = MxChat_Live_Agent_Schedule::timezone_label();
9646 $enabled = !empty($sched['enabled']);
9647 $channel_labels = array(
9648 'slack' => __('Slack', 'mxchat'),
9649 'telegram' => __('Telegram', 'mxchat'),
9650 'webhook' => __('Webhook', 'mxchat'),
9651 );
9652 $channel_label = $channel_labels[$channel];
9653 ?>
9654 <div class="mxchat-la-schedule<?php echo $enabled ? ' is-active' : ''; ?>"
9655 id="mxchat-la-schedule-<?php echo esc_attr($channel); ?>"
9656 data-channel="<?php echo esc_attr($channel); ?>">
9657 <label class="toggle-switch">
9658 <input type="checkbox" id="<?php echo esc_attr($field); ?>_enabled"
9659 class="mxchat-la-field mxchat-la-enabled" <?php checked($enabled); ?> />
9660 <span class="slider"></span>
9661 </label>
9662 <label for="<?php echo esc_attr($field); ?>_enabled" class="mxchat-status-label">
9663 <span class="status-text mxchat-la-status-text">
9664 <?php echo $enabled
9665 ? esc_html__('Scheduled hours', 'mxchat')
9666 : esc_html__('Always available', 'mxchat'); ?>
9667 </span>
9668 </label>
9669
9670 <div class="mxchat-la-days" aria-hidden="<?php echo $enabled ? 'false' : 'true'; ?>">
9671 <?php foreach ($labels as $n => $label) :
9672 $day = $sched['days'][$n];
9673 $on = !empty($day['enabled']);
9674 ?>
9675 <div class="mxchat-la-day<?php echo $on ? ' is-on' : ''; ?>" data-day="<?php echo esc_attr($n); ?>">
9676 <label class="mxchat-la-day-label">
9677 <input type="checkbox" class="mxchat-la-field mxchat-la-day-enabled"
9678 data-day="<?php echo esc_attr($n); ?>" <?php checked($on); ?> />
9679 <span class="mxchat-la-day-name"><?php echo esc_html($label); ?></span>
9680 </label>
9681 <div class="mxchat-la-times">
9682 <input type="time" class="mxchat-la-field mxchat-la-start"
9683 data-day="<?php echo esc_attr($n); ?>"
9684 value="<?php echo esc_attr($day['start']); ?>"
9685 aria-label="<?php echo esc_attr(sprintf(__('%s start time', 'mxchat'), $label)); ?>" />
9686 <span class="mxchat-la-dash">&ndash;</span>
9687 <input type="time" class="mxchat-la-field mxchat-la-end"
9688 data-day="<?php echo esc_attr($n); ?>"
9689 value="<?php echo esc_attr($day['end']); ?>"
9690 aria-label="<?php echo esc_attr(sprintf(__('%s end time', 'mxchat'), $label)); ?>" />
9691 </div>
9692 </div>
9693 <?php endforeach; ?>
9694 </div>
9695
9696 <input type="hidden" name="<?php echo esc_attr($field); ?>" id="<?php echo esc_attr($field); ?>"
9697 class="mxchat-la-hidden" value="<?php echo esc_attr(wp_json_encode($sched)); ?>" />
9698 </div>
9699 <p class="description">
9700 <?php printf(
9701 /* translators: 1: the handoff channel, e.g. Slack or Telegram; 2: the site's timezone, e.g. America/New_York */
9702 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'),
9703 '<strong>' . esc_html($channel_label) . '</strong>',
9704 '<strong>' . esc_html($tz) . '</strong>'
9705 ); ?>
9706 </p>
9707 <?php
9708 }
9709
9710 public function mxchat_live_agent_away_message_callback() {
9711 $message = isset($this->options['live_agent_away_message'])
9712 ? $this->options['live_agent_away_message']
9713 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9714
9715 printf(
9716 '<textarea id="live_agent_away_message" name="live_agent_away_message" rows="3" cols="50">%s</textarea>',
9717 esc_textarea($message)
9718 );
9719 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9720 }
9721
9722 public function mxchat_live_agent_notification_message_callback() {
9723 $message = isset($this->options['live_agent_notification_message'])
9724 ? $this->options['live_agent_notification_message']
9725 : __('Live agent has been notified.', 'mxchat');
9726
9727 printf(
9728 '<textarea id="live_agent_notification_message" name="live_agent_notification_message" rows="3" cols="50">%s</textarea>',
9729 esc_textarea($message)
9730 );
9731 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9732 }
9733
9734 public function mxchat_live_agent_webhook_url_callback() {
9735 $webhook_url = isset($this->options['live_agent_webhook_url'])
9736 ? esc_url($this->options['live_agent_webhook_url'])
9737 : esc_url(get_option('live_agent_webhook_url', ''));
9738
9739 printf(
9740 '<input type="password" id="live_agent_webhook_url" name="live_agent_webhook_url" value="%s" class="regular-text" />',
9741 $webhook_url
9742 );
9743 echo '<button type="button" id="toggleWebhookUrlVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9744 echo '<p class="description">' . esc_html__('Enter your Slack webhook URL for live agent notifications.', 'mxchat') . '</p>';
9745 }
9746
9747 public function mxchat_live_agent_secret_key_callback() {
9748 printf(
9749 '<input type="password" id="live_agent_secret_key" name="live_agent_secret_key" value="%s" class="regular-text" />',
9750 isset($this->options['live_agent_secret_key']) ? esc_attr($this->options['live_agent_secret_key']) : ''
9751 );
9752 echo '<button type="button" id="toggleSecretKeyVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9753 echo '<p class="description">' . esc_html__('Secret key for validating Slack requests. Keep this secure.', 'mxchat') . '</p>';
9754 }
9755
9756 public function mxchat_live_agent_bot_token_callback() {
9757 printf(
9758 '<input type="password" id="live_agent_bot_token" name="live_agent_bot_token" value="%s" class="regular-text" />',
9759 isset($this->options['live_agent_bot_token']) ? esc_attr($this->options['live_agent_bot_token']) : ''
9760 );
9761 echo '<button type="button" id="toggleBotTokenVisibility">' . esc_html__('Show', 'mxchat') . '</button>';
9762 echo '<p class="description">' . esc_html__('Your Slack Bot OAuth Token (starts with xoxb-). Keep this secure.', 'mxchat') . '</p>';
9763 }
9764
9765 public function mxchat_live_agent_user_ids_callback() {
9766 $user_ids = isset($this->options['live_agent_user_ids'])
9767 ? esc_textarea($this->options['live_agent_user_ids'])
9768 : '';
9769
9770 printf(
9771 '<textarea id="live_agent_user_ids" name="live_agent_user_ids" rows="4" class="large-text">%s</textarea>',
9772 $user_ids
9773 );
9774 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>';
9775 }
9776
9777 public function mxchat_live_agent_shared_channel_callback() {
9778 $value = isset($this->options['live_agent_shared_channel']) ? $this->options['live_agent_shared_channel'] : '';
9779 printf(
9780 '<input type="text" id="live_agent_shared_channel" name="live_agent_shared_channel" value="%s" class="regular-text" placeholder="#support or C0123456789" />',
9781 esc_attr($value)
9782 );
9783 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>';
9784
9785 // Surface the last failed handoff so a wrong name / missing invite is
9786 // visible right where it gets fixed. A successful handoff clears this.
9787 $shared_error = get_option('mxchat_slack_shared_channel_error');
9788 if (!empty($shared_error['error']) && trim((string) $value) !== '' && ($shared_error['configured'] ?? '') === trim((string) $value)) {
9789 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this channel', 'mxchat') . '</strong> — '
9790 . esc_html(sprintf(
9791 /* translators: %s: Slack API error code */
9792 __('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'),
9793 $shared_error['error']
9794 )) . '</p>';
9795 }
9796
9797 // Private-channel warning (plan 1a2666), recorded by the privacy probe at
9798 // configuration time or when the channel id first resolves. Posting INTO
9799 // a private channel works once the bot is a member, so everything looks
9800 // configured — but Slack delivers inbound replies from private channels
9801 // as message.groups events, which the documented app setup never
9802 // subscribes to, and agent replies silently never reach the visitor.
9803 $shared_privacy = get_option('mxchat_slack_shared_channel_privacy');
9804 if (!empty($shared_privacy['is_private']) && trim((string) $value) !== '' && ($shared_privacy['configured'] ?? '') === trim((string) $value)) {
9805 echo '<p class="description"><strong>' . esc_html__('⚠ This is a private channel', 'mxchat') . '</strong> — '
9806 . 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>';
9807 }
9808 }
9809
9810 public function mxchat_live_agent_archive_on_end_callback() {
9811 $value = isset($this->options['live_agent_archive_on_end_toggle']) ? $this->options['live_agent_archive_on_end_toggle'] : 'off';
9812 printf(
9813 '<input type="checkbox" id="live_agent_archive_on_end_toggle" name="live_agent_archive_on_end_toggle" value="on" %s />',
9814 checked('on', $value, false)
9815 );
9816 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>';
9817 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>';
9818 }
9819
9820 /**
9821 * Telegram Integration Callbacks
9822 */
9823 public function mxchat_telegram_section_callback() {
9824 echo '<p>' . esc_html__('Configure Telegram integration for live agent support.', 'mxchat') . '</p>';
9825 }
9826
9827 public function mxchat_telegram_status_callback() {
9828 $fresh_options = get_option('mxchat_options');
9829 $status = isset($fresh_options['telegram_status']) ? $fresh_options['telegram_status'] : 'off';
9830
9831 echo '<label class="toggle-switch">';
9832 echo sprintf(
9833 '<input type="checkbox" id="telegram_status" name="telegram_status" value="on" %s />',
9834 checked($status, 'on', false)
9835 );
9836 echo '<span class="slider"></span>';
9837 echo '</label>';
9838 echo '<label for="telegram_status" class="mxchat-status-label">';
9839 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9840 echo '</label>';
9841 }
9842
9843 public function mxchat_telegram_notification_message_callback() {
9844 $message = isset($this->options['telegram_notification_message'])
9845 ? $this->options['telegram_notification_message']
9846 : __("I've notified a support agent. Please allow a moment for them to respond.", 'mxchat');
9847
9848 printf(
9849 '<textarea id="telegram_notification_message" name="telegram_notification_message" rows="3" cols="50">%s</textarea>',
9850 esc_textarea($message)
9851 );
9852 echo '<p class="description">' . esc_html__('Message shown when live transfer activated.', 'mxchat') . '</p>';
9853 }
9854
9855 public function mxchat_telegram_away_message_callback() {
9856 $message = isset($this->options['telegram_away_message'])
9857 ? $this->options['telegram_away_message']
9858 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9859
9860 printf(
9861 '<textarea id="telegram_away_message" name="telegram_away_message" rows="3" cols="50">%s</textarea>',
9862 esc_textarea($message)
9863 );
9864 echo '<p class="description">' . esc_html__('Message shown when live agents are offline.', 'mxchat') . '</p>';
9865 }
9866
9867 public function mxchat_telegram_bot_token_callback() {
9868 printf(
9869 '<input type="password" id="telegram_bot_token" name="telegram_bot_token" value="%s" class="regular-text" />',
9870 isset($this->options['telegram_bot_token']) ? esc_attr($this->options['telegram_bot_token']) : ''
9871 );
9872 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>';
9873 echo '<p class="description">' . esc_html__('Your Telegram Bot Token from @BotFather (e.g., 123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ).', 'mxchat') . '</p>';
9874 }
9875
9876 public function mxchat_telegram_group_id_callback() {
9877 printf(
9878 '<input type="text" id="telegram_group_id" name="telegram_group_id" value="%s" class="regular-text" />',
9879 isset($this->options['telegram_group_id']) ? esc_attr($this->options['telegram_group_id']) : ''
9880 );
9881 echo '<p class="description">' . esc_html__('Your Telegram supergroup ID (starts with -100). The group must have forum topics enabled.', 'mxchat') . '</p>';
9882
9883 // Surface the last failed Telegram API call right where it gets fixed
9884 // (plan 1aeee3). Before this existed every Telegram response was
9885 // discarded — a wrong group id, a closed topic, or an oversized handoff
9886 // all failed with no trace anywhere. Kept until the next failure
9887 // overwrites it; the timestamp shows how stale it is.
9888 $tg_error = get_option('mxchat_telegram_last_error');
9889 if (!empty($tg_error['error'])) {
9890 echo '<p class="description"><strong>' . esc_html__('⚠ Last Telegram API failure', 'mxchat') . '</strong> — '
9891 . esc_html(sprintf(
9892 /* translators: 1: Telegram API method name, 2: Telegram's error description, 3: local date and time of the failure */
9893 __('%1$s failed with "%2$s" (%3$s). If live agent chat is working now, this may be from an earlier misconfiguration.', 'mxchat'),
9894 $tg_error['method'] ?? 'sendMessage',
9895 $tg_error['error'],
9896 !empty($tg_error['time']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $tg_error['time']) : __('unknown time', 'mxchat')
9897 )) . '</p>';
9898 }
9899
9900 // The inbound counterpart of the line above (plan 30398e): outbound tells you
9901 // MxChat could not reach Telegram, this one tells you Telegram reached MxChat
9902 // and was turned away. That failure previously left nothing behind but a
9903 // single error_log() line, so a Cloudflare-fronted install with no secret set
9904 // dropped every agent reply with no admin-side trace at all. Cleared
9905 // automatically by the first accepted request.
9906 $tg_rejects = get_option('mxchat_telegram_webhook_rejects');
9907 if (is_array($tg_rejects) && !empty($tg_rejects['count'])) {
9908 $reject_count = (int) $tg_rejects['count'];
9909 echo '<p class="description" style="color:#b32d2e;"><strong>' . esc_html__('⚠ Incoming Telegram webhook requests are being rejected', 'mxchat') . '</strong> — '
9910 . esc_html(sprintf(
9911 /* translators: 1: number of rejected requests, 2: local date and time of the most recent rejection */
9912 _n(
9913 '%1$s request rejected, most recently %2$s. Agent replies are not reaching visitors.',
9914 '%1$s requests rejected, most recently %2$s. Agent replies are not reaching visitors.',
9915 $reject_count,
9916 'mxchat'
9917 ),
9918 number_format_i18n($reject_count),
9919 !empty($tg_rejects['last_ts']) ? wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $tg_rejects['last_ts']) : __('unknown time', 'mxchat')
9920 ))
9921 . ' ' . esc_html($this->mxchat_telegram_reject_advice($tg_rejects['last_reason'] ?? ''))
9922 . '</p>';
9923 }
9924 }
9925
9926 public function mxchat_telegram_webhook_secret_callback() {
9927 $secret = isset($this->options['telegram_webhook_secret']) ? $this->options['telegram_webhook_secret'] : '';
9928
9929 // Auto-generate secret if empty
9930 if (empty($secret)) {
9931 $secret = wp_generate_password(64, false, false);
9932 $options = get_option('mxchat_options', []);
9933 $options['telegram_webhook_secret'] = $secret;
9934 update_option('mxchat_options', $options);
9935 $this->options['telegram_webhook_secret'] = $secret;
9936 }
9937
9938 printf(
9939 '<input type="password" id="telegram_webhook_secret" name="telegram_webhook_secret" value="%s" class="regular-text" />',
9940 esc_attr($secret)
9941 );
9942 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>';
9943 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>';
9944 }
9945
9946 /**
9947 * Webhook Handoff Callbacks (plan d88e22)
9948 */
9949 public function mxchat_webhook_handoff_section_callback() {
9950 echo '<p>' . esc_html__('Send live-agent handoffs to any URL you choose — your helpdesk, an automation flow, a CRM.', 'mxchat') . '</p>';
9951 }
9952
9953 public function mxchat_webhook_handoff_status_callback() {
9954 $fresh_options = get_option('mxchat_options');
9955 $status = isset($fresh_options['webhook_handoff_status']) ? $fresh_options['webhook_handoff_status'] : 'off';
9956
9957 echo '<label class="toggle-switch">';
9958 echo sprintf(
9959 '<input type="checkbox" id="webhook_handoff_status" name="webhook_handoff_status" value="on" %s />',
9960 checked($status, 'on', false)
9961 );
9962 echo '<span class="slider"></span>';
9963 echo '</label>';
9964 echo '<label for="webhook_handoff_status" class="mxchat-status-label">';
9965 echo '<span class="status-text">' . ($status === 'on' ? esc_html__('Online', 'mxchat') : esc_html__('Offline', 'mxchat')) . '</span>';
9966 echo '</label>';
9967 }
9968
9969 public function mxchat_webhook_handoff_notification_message_callback() {
9970 $message = isset($this->options['webhook_handoff_notification_message'])
9971 ? $this->options['webhook_handoff_notification_message']
9972 : __("I've notified our support team — they'll follow up with you soon. Meanwhile, I'm happy to keep helping.", 'mxchat');
9973
9974 printf(
9975 '<textarea id="webhook_handoff_notification_message" name="webhook_handoff_notification_message" rows="3" cols="50">%s</textarea>',
9976 esc_textarea($message)
9977 );
9978 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>';
9979 }
9980
9981 public function mxchat_webhook_handoff_away_message_callback() {
9982 $message = isset($this->options['webhook_handoff_away_message'])
9983 ? $this->options['webhook_handoff_away_message']
9984 : __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat');
9985
9986 printf(
9987 '<textarea id="webhook_handoff_away_message" name="webhook_handoff_away_message" rows="3" cols="50">%s</textarea>',
9988 esc_textarea($message)
9989 );
9990 echo '<p class="description">' . esc_html__('Message shown when this destination is offline or outside its scheduled hours.', 'mxchat') . '</p>';
9991 }
9992
9993 public function mxchat_webhook_handoff_url_callback() {
9994 $value = isset($this->options['webhook_handoff_url']) ? $this->options['webhook_handoff_url'] : '';
9995 printf(
9996 '<input type="url" id="webhook_handoff_url" name="webhook_handoff_url" value="%s" class="regular-text" placeholder="https://example.com/hooks/mxchat" />',
9997 esc_attr($value)
9998 );
9999 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>';
10000
10001 // Surface the last failed delivery right where it gets fixed — a handoff
10002 // that silently 404s is worse than no handoff. A successful one clears this.
10003 $webhook_error = get_option('mxchat_webhook_handoff_error');
10004 if (!empty($webhook_error['error']) && trim((string) $value) !== '' && ($webhook_error['configured'] ?? '') === trim((string) $value)) {
10005 echo '<p class="description"><strong>' . esc_html__('⚠ Last handoff could not reach this URL', 'mxchat') . '</strong> — '
10006 . esc_html(sprintf(
10007 /* translators: %s: the delivery failure, e.g. an HTTP status or a transport error */
10008 __('the request failed with "%s". Until this is fixed, the chatbot answers these conversations itself instead of telling visitors a human was notified.', 'mxchat'),
10009 $webhook_error['error']
10010 )) . '</p>';
10011 }
10012 }
10013
10014 public function mxchat_webhook_handoff_secret_callback() {
10015 printf(
10016 '<input type="password" id="webhook_handoff_secret" name="webhook_handoff_secret" value="%s" class="regular-text" />',
10017 isset($this->options['webhook_handoff_secret']) ? esc_attr($this->options['webhook_handoff_secret']) : ''
10018 );
10019 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>';
10020 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>';
10021 }
10022
10023 public function mxchat_similarity_threshold_callback() {
10024 // Load from mxchat_options array
10025 $options = get_option('mxchat_options', []);
10026
10027 // Get value from options array with default of 35
10028 $threshold = isset($options['similarity_threshold']) ? $options['similarity_threshold'] : 35;
10029
10030 echo '<div class="slider-container">';
10031 echo sprintf(
10032 '<input type="range"
10033 id="similarity_threshold"
10034 name="similarity_threshold"
10035 min="20"
10036 max="85"
10037 step="1"
10038 value="%s"
10039 class="range-slider" />',
10040 esc_attr($threshold)
10041 );
10042 echo sprintf(
10043 '<span id="threshold_value" class="range-value">%s</span>',
10044 esc_html($threshold)
10045 );
10046 echo '</div>';
10047 }
10048
10049 public function mxchat_max_input_length_callback() {
10050 // Load from mxchat_options array (plan a3fae2 part C).
10051 $options = get_option('mxchat_options', []);
10052
10053 // 0 = unlimited (default — preserves current behavior, no cap).
10054 $max_input_length = isset($options['max_input_length']) ? intval($options['max_input_length']) : 0;
10055
10056 echo sprintf(
10057 '<input type="number"
10058 id="max_input_length"
10059 name="max_input_length"
10060 min="0"
10061 max="100000"
10062 step="1"
10063 value="%s"
10064 placeholder="0"
10065 class="mxch-input mxch-input-sm" />',
10066 esc_attr($max_input_length)
10067 );
10068 }
10069
10070 public function mxchat_rag_sources_limit_callback() {
10071 // Load from mxchat_options array
10072 $options = get_option('mxchat_options', []);
10073
10074 // Get value from options array with default of 3
10075 $rag_sources_limit = isset($options['rag_sources_limit']) ? intval($options['rag_sources_limit']) : 3;
10076
10077 echo '<div class="slider-container">';
10078 echo sprintf(
10079 '<input type="range"
10080 id="rag_sources_limit"
10081 name="rag_sources_limit"
10082 min="3"
10083 max="10"
10084 step="1"
10085 value="%s"
10086 class="range-slider" />',
10087 esc_attr($rag_sources_limit)
10088 );
10089 echo sprintf(
10090 '<span id="rag_sources_limit_value" class="range-value">%s</span>',
10091 esc_html($rag_sources_limit)
10092 );
10093 echo '</div>';
10094 }
10095
10096 public function mxchat_rag_chunks_limit_callback() {
10097 // Load from mxchat_options array
10098 $options = get_option('mxchat_options', []);
10099
10100 // Get value from options array with default of 15
10101 $rag_chunks_limit = isset($options['rag_chunks_limit']) ? intval($options['rag_chunks_limit']) : 15;
10102
10103 echo '<div class="slider-container">';
10104 echo sprintf(
10105 '<input type="range"
10106 id="rag_chunks_limit"
10107 name="rag_chunks_limit"
10108 min="8"
10109 max="20"
10110 step="1"
10111 value="%s"
10112 class="range-slider" />',
10113 esc_attr($rag_chunks_limit)
10114 );
10115 echo sprintf(
10116 '<span id="rag_chunks_limit_value" class="range-value">%s</span>',
10117 esc_html($rag_chunks_limit)
10118 );
10119 echo '</div>';
10120 }
10121
10122 /**
10123 * Add body class on the Onboarding wizard page so CSS-only chrome surgery
10124 * (collapsing the WP admin sidebar) only applies on this page.
10125 * Plan: plan-mxchat-20260527-905439.
10126 */
10127 public function mxchat_add_onboarding_body_class($classes) {
10128 if (isset($_GET['page']) && $_GET['page'] === 'mxchat-onboarding') {
10129 $classes .= ' mxchat-onboarding-focused';
10130 }
10131 return $classes;
10132 }
10133
10134 public function mxchat_enqueue_admin_assets($hook_suffix = '') {
10135 // Authorization gate (plan-mxchat-20260731-c63fb6). Previously the ONLY
10136 // guard here was the strpos() on $_GET['page'] below — which is
10137 // attacker-controlled — so ANY logged-in user (Subscriber included) could
10138 // load a page they are legitimately allowed to see, e.g.
10139 // /wp-admin/profile.php?page=mxchat, and receive every nonce this method
10140 // localizes. Three of the handlers behind those nonces verified the nonce
10141 // but checked no capability. Every MxChat menu page is registered
10142 // 'manage_options' (see mxchat_add_admin_menu), so this is a no-op for
10143 // legitimate users.
10144 if (!current_user_can('manage_options')) {
10145 return;
10146 }
10147
10148 // Get plugin version
10149 $version = MXCHAT_VERSION;
10150
10151 // Use file modification time for development (remove in production)
10152 if (defined('WP_DEBUG') && WP_DEBUG) {
10153 $version = filemtime(plugin_dir_path(__FILE__) . '../mxchat-basic.php');
10154 }
10155
10156 $current_page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
10157 $plugin_url = plugin_dir_url(__FILE__) . '../';
10158
10159 // Only load on MxChat pages. Prefer the server-derived $hook_suffix that
10160 // admin_enqueue_scripts passes us — unlike $_GET['page'] it cannot be
10161 // forged onto a screen MxChat does not own. Falls back to the legacy
10162 // $_GET check only when the hook is unavailable (direct/legacy callers).
10163 if ($hook_suffix !== '') {
10164 if (strpos($hook_suffix, 'mxchat') === false) {
10165 return;
10166 }
10167 } elseif (strpos($current_page, 'mxchat') === false) {
10168 return;
10169 }
10170
10171 // Always load these on all MxChat pages
10172 $this->enqueue_core_admin_assets($plugin_url, $version);
10173 $this->enqueue_page_specific_assets($current_page, $plugin_url, $version);
10174 $this->localize_admin_scripts($current_page);
10175 }
10176 private function enqueue_core_admin_assets($plugin_url, $version) {
10177 // Core admin styles
10178 wp_enqueue_style('mxchat-admin-css', $plugin_url . 'css/admin-style.css', array(), $version);
10179 wp_enqueue_style('mxchat-knowledge-css', $plugin_url . 'css/knowledge-style.css', array(), $version);
10180
10181 // New sidebar navigation styles (for main settings page)
10182 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10183
10184 // Core admin scripts
10185 wp_enqueue_script('mxchat-admin-js', $plugin_url . 'js/mxchat-admin.js', array('jquery'), $version, true);
10186 }
10187 private function enqueue_page_specific_assets($current_page, $plugin_url, $version) {
10188 switch ($current_page) {
10189 case 'mxchat-prompts':
10190 // Shared shell JS: tab switching, deep-linking and the mobile menu
10191 // for this page's flat nav (inline copies removed by plan c192a4).
10192 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10193 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10194 'copied' => __('Copied', 'mxchat'),
10195 ));
10196 // Knowledge processing page assets
10197 wp_enqueue_style('mxchat-content-selector-css', $plugin_url . 'css/content-selector.css', array(), $version);
10198 wp_enqueue_script('mxchat-content-selector-js', $plugin_url . 'js/content-selector.js', array('jquery'), $version, true);
10199 // Add the knowledge processing script (common script needed for WordPress dismiss functionality)
10200 wp_enqueue_script('mxchat-knowledge-processing', $plugin_url . 'js/knowledge-processing.js', array('jquery', 'common'), $version, true);
10201 // Per-entry "View indexed content" inspector (plan-d8cb4b) reuses the
10202 // Testing tab's match-card / chunk-detail components, which are scoped
10203 // under .mxch-testing-results. Load that stylesheet here so the inspector
10204 // modal renders identically to the Testing tab.
10205 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css'), $version);
10206 break;
10207
10208 case 'mxchat-transcripts':
10209 // Load admin sidebar CSS first (shared styles for sidebar navigation)
10210 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10211 // Load transcripts-specific styles
10212 wp_enqueue_style('mxchat-chat-transcripts-css', $plugin_url . 'css/chat-transcripts.css', array('mxchat-admin-sidebar-css'), $version);
10213 wp_enqueue_script('mxchat-transcripts-js', $plugin_url . 'js/mxchat_transcripts.js', array('jquery'), $version, true);
10214 break;
10215
10216 case 'mxchat-actions':
10217 // Load admin sidebar CSS first (shared styles for sidebar navigation)
10218 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10219 // Load actions-specific styles
10220 wp_enqueue_style('mxchat-actions-css', $plugin_url . 'css/actions.css', array('mxchat-admin-sidebar-css'), $version);
10221 wp_enqueue_script('mxchat-actions-js', $plugin_url . 'js/mxchat_actions.js', array('jquery'), $version, true);
10222
10223 // Localize script data for actions page
10224 $is_activated = get_option('mxchat_pro_license_status') === 'active';
10225 wp_localize_script('mxchat-actions-js', 'mxchActionsData', array(
10226 'ajaxUrl' => admin_url('admin-ajax.php'),
10227 'nonce' => wp_create_nonce('mxchat_actions_nonce'),
10228 'addNonce' => wp_create_nonce('mxchat_add_intent_nonce'),
10229 'editNonce' => wp_create_nonce('mxchat_edit_intent'),
10230 'deleteNonce' => wp_create_nonce('mxchat_delete_intent_nonce'),
10231 'toggleNonce' => wp_create_nonce('mxchat_actions_nonce'),
10232 'addPhraseNonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
10233 'deletePhraseNonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
10234 'getPhrasesNonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
10235 'deleteLegacyNonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
10236 'isActivated' => $is_activated,
10237 'i18n' => array(
10238 'confirmDelete' => __('Are you sure you want to delete this trigger phrase?', 'mxchat'),
10239 'confirmBulkDelete' => __('Are you sure you want to delete the selected trigger phrases?', 'mxchat'),
10240 'saving' => __('Saving...', 'mxchat'),
10241 'saved' => __('Saved successfully!', 'mxchat'),
10242 'error' => __('An error occurred. Please try again.', 'mxchat'),
10243 'proRequired' => __('This feature requires MxChat Pro.', 'mxchat'),
10244 'addonRequired' => __('This feature requires an add-on.', 'mxchat')
10245 )
10246 ));
10247 break;
10248
10249 case 'mxchat-activation':
10250 // Load admin sidebar CSS first (shared styles for sidebar navigation)
10251 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10252 // Load pro page-specific styles
10253 wp_enqueue_style('mxchat-pro-css', $plugin_url . 'css/admin-pro.css', array('mxchat-admin-sidebar-css'), $version);
10254 // Load pro page JavaScript
10255 wp_enqueue_script('mxchat-pro-js', $plugin_url . 'js/mxchat_pro.js', array('jquery'), $version, true);
10256 // Load activation script for license activation/deactivation
10257 wp_enqueue_script('mxchat-activation-js', $plugin_url . 'js/activation-script.js', array('jquery'), $version, true);
10258 break;
10259
10260 case 'mxchat-content':
10261 // Load admin sidebar CSS (shared styles for sidebar navigation)
10262 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10263 // Load content page-specific styles
10264 wp_enqueue_style('mxchat-content-css', $plugin_url . 'css/admin-content.css', array('mxchat-admin-sidebar-css'), $version);
10265 // Load content page JavaScript
10266 wp_enqueue_script('mxchat-content-js', $plugin_url . 'js/mxchat-content.js', array('jquery'), $version, true);
10267 break;
10268
10269 case 'mxchat-api-access':
10270 // Shared sidebar shell (CSS + JS for tab switching / mobile menu / copy buttons).
10271 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10272 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10273 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10274 'copied' => __('Copied', 'mxchat'),
10275 ));
10276 break;
10277
10278 case 'mxchat-max':
10279 case 'mxchat-onboarding':
10280 // Onboarding page — uses the shared admin shell PLUS the wizard
10281 // overlay (plan-905439). admin-onboarding-wizard.css scopes the
10282 // WP-chrome surgery to body.mxchat-onboarding-focused so it only
10283 // applies on THIS page. The body class is added below via the
10284 // admin_body_class filter.
10285 wp_enqueue_style('mxchat-admin-sidebar-css', $plugin_url . 'css/admin-sidebar.css', array(), $version);
10286 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10287 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10288 'copied' => __('Copied', 'mxchat'),
10289 ));
10290 // admin-style.css provides the .mxchat-instructions-modal-* classes
10291 // the new Behavior step's "View Sample Instructions" modal needs.
10292 // Enqueued AFTER admin-sidebar.css but BEFORE the wizard overlay
10293 // so the wizard's own rules win where they collide. plan-a2e4d6.
10294 wp_enqueue_style('mxchat-admin-style-css', $plugin_url . 'css/admin-style.css', array('mxchat-admin-sidebar-css'), $version);
10295 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);
10296 wp_enqueue_script('mxchat-admin-onboarding-wizard-js', $plugin_url . 'js/admin-onboarding-wizard.js', array(), $version, true);
10297 break;
10298 default:
10299 // Settings page: load the shared shell JS for #hash deep-linking
10300 // to tabs (plan 4ede16). The page's own inline nav script owns
10301 // clicks/accordion behaviour; the shell script adds load +
10302 // hashchange activation and replaceState so plain href="#tab"
10303 // anchors and shared URLs land on the right tab. Kept inside the
10304 // default case because the settings page also needs everything
10305 // below (Testing tab chatbot + streaming assets).
10306 if ($current_page === 'mxchat-settings') {
10307 wp_enqueue_script('mxchat-admin-sidebar-js', $plugin_url . 'js/admin-sidebar.js', array(), $version, true);
10308 wp_localize_script('mxchat-admin-sidebar-js', 'MxChatAdminSidebarI18n', array(
10309 'copied' => __('Copied', 'mxchat'),
10310 ));
10311 }
10312
10313 wp_enqueue_script(
10314 'mxchat-test-streaming-js',
10315 $plugin_url . 'js/mxchat-test-streaming.js',
10316 ['jquery'],
10317 $version,
10318 true
10319 );
10320
10321 wp_localize_script('mxchat-test-streaming-js', 'mxchatTestStreamingAjax', [
10322 'ajax_url' => admin_url('admin-ajax.php'),
10323 'nonce' => wp_create_nonce('mxchat_test_streaming_nonce'),
10324 'settings_nonce' => wp_create_nonce('mxchat_save_setting_nonce')
10325 ]);
10326
10327 // Testing Tab: Load chatbot assets for the embedded testing chatbot
10328 wp_enqueue_style('mxchat-chat-css', $plugin_url . 'css/chat-style.css', array(), $version);
10329 wp_enqueue_style('mxchat-admin-testing-css', $plugin_url . 'css/admin-testing-tab.css', array('mxchat-admin-sidebar-css', 'mxchat-chat-css'), $version);
10330
10331 wp_enqueue_script('mxchat-chat-js', $plugin_url . 'js/chat-script.js', array('jquery'), $version, true);
10332 wp_enqueue_script('mxchat-admin-testing-js', $plugin_url . 'js/admin-testing-tab.js', array('jquery', 'mxchat-chat-js'), $version, true);
10333
10334 // Allow add-ons to enqueue their public CSS/JS for the testing chatbot
10335 do_action('mxchat_enqueue_testing_tab_assets');
10336
10337 // Localize mxchatChat for the chatbot JS (same settings as frontend)
10338 $options = get_option('mxchat_options', array());
10339 $prompts_options = get_option('mxchat_prompts_options', array());
10340 $theme_options = get_option('mxchat_theme_options', array());
10341 $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
10342 $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
10343 $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
10344
10345 wp_localize_script('mxchat-chat-js', 'mxchatChat', array(
10346 'ajax_url' => admin_url('admin-ajax.php'),
10347 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
10348 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
10349 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
10350 'contextual_awareness_toggle' => isset($options['contextual_awareness_toggle']) ? $options['contextual_awareness_toggle'] : 'off',
10351 'link_target_toggle' => $options['link_target_toggle'] ?? 'off',
10352 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
10353 'complianz_toggle' => isset($options['complianz_toggle']) && $options['complianz_toggle'] === 'on',
10354 'user_message_bg_color' => $options['user_message_bg_color'] ?? '#fff',
10355 'user_message_font_color' => $options['user_message_font_color'] ?? '#212121',
10356 'bot_message_bg_color' => $options['bot_message_bg_color'] ?? '#212121',
10357 'bot_message_font_color' => $options['bot_message_font_color'] ?? '#fff',
10358 'top_bar_bg_color' => $options['top_bar_bg_color'] ?? '#212121',
10359 'send_button_font_color' => $options['send_button_font_color'] ?? '#212121',
10360 'close_button_color' => $options['close_button_color'] ?? '#fff',
10361 'chatbot_background_color' => $options['chatbot_background_color'] ?? '#212121',
10362 'chatbot_bg_color' => $options['chatbot_bg_color'] ?? '#fff',
10363 'icon_color' => $options['icon_color'] ?? '#fff',
10364 'chat_input_font_color' => $options['chat_input_font_color'] ?? '#212121',
10365 'chat_persistence_toggle' => 'off', // Always off for testing chatbot
10366 'appendWidgetToBody' => 'off',
10367 'live_agent_message_bg_color' => $options['live_agent_message_bg_color'] ?? '#ffffff',
10368 'live_agent_message_font_color' => $options['live_agent_message_font_color'] ?? '#333333',
10369 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
10370 'mode_indicator_bg_color' => $options['mode_indicator_bg_color'] ?? '#767676',
10371 'mode_indicator_font_color' => $options['mode_indicator_font_color'] ?? '#ffffff',
10372 'toolbar_icon_color' => $options['toolbar_icon_color'] ?? '#212121',
10373 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
10374 'email_collection_enabled' => 'off', // No email collection in testing
10375 'initial_email_state' => null,
10376 'skip_email_check' => true,
10377 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
10378 'skip_inline_colors' => $skip_inline_colors,
10379 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array()
10380 ));
10381
10382 // Localize testing tab script data
10383 wp_localize_script('mxchat-admin-testing-js', 'mxchatAdminTestData', array(
10384 'ajaxUrl' => admin_url('admin-ajax.php'),
10385 'nonce' => wp_create_nonce('mxchat_test_nonce'),
10386 'isAdmin' => true,
10387 'testingEnabled' => true
10388 ));
10389
10390 // Add testing enabled flag for the chatbot to return debug data
10391 add_action('admin_footer', function() {
10392 echo '<script>window.mxchatTestingEnabled = true;</script>';
10393 });
10394
10395 break;
10396 }
10397 }
10398 /**
10399 * The base mxchatAdmin payload shared by every MxChat admin page.
10400 *
10401 * Extracted (plan c192a4) so page-specific re-localizations of the SAME
10402 * global — the knowledge page prints a second `var mxchatAdmin` via the
10403 * mxchat-knowledge-processing handle, and last-printed wins — can merge
10404 * their extra keys on top of this instead of silently narrowing the object
10405 * that mxchat-admin.js also reads on that page.
10406 */
10407 private function mxchat_get_admin_base_localization() {
10408 return array(
10409 'ajax_url' => admin_url('admin-ajax.php'),
10410 'nonce' => wp_create_nonce('mxchat_admin_nonce'),
10411 'admin_url' => admin_url(),
10412 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce'),
10413 'inline_edit_nonce' => wp_create_nonce('mxchat_save_inline_nonce'),
10414 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10415 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
10416 'actions_nonce' => wp_create_nonce('mxchat_actions_nonce'),
10417 'add_intent_nonce' => wp_create_nonce('mxchat_add_intent_nonce'),
10418 'edit_intent_nonce' => wp_create_nonce('mxchat_edit_intent'),
10419 'add_phrase_nonce' => wp_create_nonce('mxchat_add_phrase_nonce'),
10420 'delete_phrase_nonce' => wp_create_nonce('mxchat_delete_phrase_nonce'),
10421 'get_phrases_nonce' => wp_create_nonce('mxchat_get_phrases_nonce'),
10422 'delete_legacy_nonce' => wp_create_nonce('mxchat_delete_legacy_nonce'),
10423 'toggle_action_nonce' => wp_create_nonce('mxchat_actions_nonce'),
10424 'fetch_openrouter_models_nonce' => wp_create_nonce('mxchat_fetch_openrouter_models'),
10425 'is_activated' => $this->is_activated ? '1' : '0',
10426 'status_refresh_interval' => 5000,
10427 'discard_changes_confirm' => __('Discard your unsaved changes?', 'mxchat'),
10428 'unsaved_label' => __('Unsaved', 'mxchat'),
10429 // Live-agent schedule status text (plan 8ccaa2).
10430 'i18n_scheduled_hours' => __('Scheduled hours', 'mxchat'),
10431 'i18n_always_available' => __('Always available', 'mxchat'),
10432 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10433 'ajaxurl' => admin_url('admin-ajax.php')
10434 );
10435 }
10436 private function localize_admin_scripts($current_page) {
10437 // Base localization data for main admin script
10438 $base_data = $this->mxchat_get_admin_base_localization();
10439
10440 // Localize main admin script with base data
10441 wp_localize_script('mxchat-admin-js', 'mxchatAdmin', $base_data);
10442
10443 // Canonical chat-model catalog for the modal picker grid
10444 // (plan-d14e89). Adding a model in class-mxchat-model-catalog.php
10445 // automatically appears here.
10446 if (!class_exists('MxChat_Model_Catalog')) {
10447 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10448 }
10449 wp_localize_script('mxchat-admin-js', 'mxchatChatModelCatalog', MxChat_Model_Catalog::js_picker_shape());
10450
10451 // Page-specific localizations
10452 $this->localize_page_specific_scripts($current_page);
10453 }
10454 private function localize_page_specific_scripts($current_page) {
10455 switch ($current_page) {
10456 case 'mxchat-prompts':
10457 // Status updater localization
10458 wp_localize_script('mxchat-status-updater', 'mxchat_status_data', array(
10459 'ajax_url' => admin_url('admin-ajax.php'),
10460 'nonce' => wp_create_nonce('mxchat_status_nonce')
10461 ));
10462
10463 // Content selector localization
10464 wp_localize_script('mxchat-content-selector-js', 'mxchatSelector', array(
10465 'ajaxurl' => admin_url('admin-ajax.php'),
10466 'nonce' => wp_create_nonce('mxchat_content_selector_nonce'),
10467 'i18n' => array(
10468 'searchPlaceholder' => __('Search posts and pages...', 'mxchat'),
10469 'selectAll' => __('Select All', 'mxchat'),
10470 'process' => __('Process Selected', 'mxchat'),
10471 'cancel' => __('Cancel', 'mxchat'),
10472 'noResults' => __('No content found.', 'mxchat'),
10473 'extractingPdfs' => __('extracting %d PDF(s)...', 'mxchat'),
10474 'pdfExtractedSuffix' => __(' (%d PDF(s) extracted)', 'mxchat')
10475 )
10476 ));
10477
10478 // This prints a SECOND `var mxchatAdmin` (after mxchat-admin-js's
10479 // base one) and last-printed wins — so it must carry the FULL base
10480 // payload plus the knowledge-page extras, or every mxchatAdmin.*
10481 // key mxchat-admin.js reads on this page comes up undefined
10482 // (plan c192a4). Extras merge last: status_refresh_interval stays
10483 // at this page's faster 2000ms cadence.
10484 wp_localize_script('mxchat-knowledge-processing', 'mxchatAdmin', array_merge(
10485 $this->mxchat_get_admin_base_localization(),
10486 array(
10487 'status_nonce' => wp_create_nonce('mxchat_status_nonce'),
10488 'queue_nonce' => wp_create_nonce('mxchat_queue_nonce'),
10489 'stop_nonce' => wp_create_nonce('mxchat_stop_processing_action'),
10490 'settings_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10491 'entries_nonce' => wp_create_nonce('mxchat_entries_nonce'),
10492 'status_refresh_interval' => 2000,
10493 'bot_id' => isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default'
10494 )
10495 ));
10496 break;
10497
10498 case 'mxchat-activation':
10499 // Same last-printed-wins clobber as the knowledge page (plan c192a4,
10500 // fixed here by a5b297): this second `var mxchatAdmin` must carry
10501 // the full base payload plus the page extras.
10502 wp_localize_script('mxchat-activation-js', 'mxchatAdmin', array_merge(
10503 $this->mxchat_get_admin_base_localization(),
10504 array(
10505 'license_nonce' => wp_create_nonce('mxchat_activate_license_nonce')
10506 )
10507 ));
10508 break;
10509
10510 case 'mxchat-content':
10511 wp_localize_script('mxchat-content-js', 'mxchatContent', array(
10512 'ajaxUrl' => admin_url('admin-ajax.php'),
10513 'nonce' => wp_create_nonce('mxchat_content_nonce'),
10514 'settingNonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10515 'previewUrl' => home_url('/?p='),
10516 'isActivated' => $this->is_activated(),
10517 'hasAdvancedContent' => apply_filters('mxchat_content_pro_feature', false, 'seo_readability'),
10518 'hasGSC' => apply_filters('mxchat_content_pro_feature', false, 'gsc_integration'),
10519 // Whether Search Console is actually LINKED — distinct from hasGSC
10520 // (add-on active). Reads the same option the add-on's settings
10521 // card keys off; harmless false when the add-on is absent.
10522 'gscLinked' => (bool) get_option('mxchat_gsc_connected', false),
10523 'seoOptimize' => array(
10524 'meta_description' => ($options['seo_optimize_meta_desc'] ?? 'on') === 'on',
10525 'seo_title' => ($options['seo_optimize_seo_title'] ?? 'on') === 'on',
10526 'slug' => ($options['seo_optimize_slug'] ?? 'on') === 'on',
10527 'readability' => ($options['seo_optimize_readability'] ?? 'on') === 'on',
10528 'internal_links' => ($options['seo_optimize_internal_links'] ?? 'on') === 'on',
10529 'img_alt' => ($options['seo_optimize_img_alt'] ?? 'on') === 'on',
10530 'featured_img' => ($options['seo_optimize_featured_img'] ?? 'on') === 'on',
10531 ),
10532 'i18n' => array(
10533 'generating' => __('Generating...', 'mxchat'),
10534 'planning' => __('Planning content structure...', 'mxchat'),
10535 'images' => __('Generating images...', 'mxchat'),
10536 'writing' => __('Writing full content...', 'mxchat'),
10537 'creating' => __('Creating WordPress post...', 'mxchat'),
10538 'done' => __('Content generated successfully!', 'mxchat'),
10539 'error' => __('An error occurred. Please try again.', 'mxchat'),
10540 'editSuccess' => __('Content updated successfully.', 'mxchat'),
10541 'promptEmpty' => __('Please enter a prompt.', 'mxchat'),
10542 )
10543 ));
10544 break;
10545
10546 case 'mxchat-transcripts':
10547 // Get chart data for localization
10548 $chart_data = $this->get_transcripts_chart_data();
10549
10550 // Same last-printed-wins clobber as the knowledge page (plan c192a4,
10551 // fixed here by a5b297): merge the base payload under the page keys.
10552 // export_nonce/setting_nonce/ajax_url duplicate base values identically;
10553 // delete_nonce and translate_nonce are the page-specific adds.
10554 wp_localize_script('mxchat-transcripts-js', 'mxchatAdmin', array_merge(
10555 $this->mxchat_get_admin_base_localization(),
10556 array(
10557 'export_nonce' => wp_create_nonce('mxchat_export_transcripts'),
10558 'delete_nonce' => wp_create_nonce('mxchat_delete_chat_history'),
10559 'setting_nonce' => wp_create_nonce('mxchat_save_setting_nonce'),
10560 'translate_nonce' => wp_create_nonce('mxchat_translate_messages')
10561 )
10562 ));
10563
10564 // Localize chart data separately - use array_values to ensure proper JSON array encoding
10565 wp_localize_script('mxchat-transcripts-js', 'mxchatChartData', array(
10566 'labels' => array_values($chart_data['labels']),
10567 'chats' => array_values($chart_data['chats']),
10568 'messages' => array_values($chart_data['messages'])
10569 ));
10570 break;
10571
10572 case 'mxchat-settings':
10573 default:
10574 // The 'mxchatStyleSettings' localize that used to live here was
10575 // REMOVED by plan-mxchat-20260731-c63fb6.
10576 //
10577 // It targeted the script handle 'mxchat-color-picker', which is
10578 // registered/enqueued NOWHERE in the plugin — so wp_localize_script()
10579 // returned false and printed nothing. That accident was the only
10580 // thing keeping it from being a live secret disclosure: the payload
10581 // carried loops_api_key, live_agent_secret_key and
10582 // live_agent_bot_token in full, and this whole method ran for any
10583 // logged-in user (see the capability gate added to
10584 // mxchat_enqueue_admin_assets). One future
10585 // wp_enqueue_script('mxchat-color-picker', ...) would have printed
10586 // three secrets into page HTML for Subscribers.
10587 //
10588 // Nothing consumed the object: a tree-wide grep for
10589 // 'mxchatStyleSettings' returns only this call site, and js/ names
10590 // loops_api_key only at mxchat-admin.js:572, as a field-NAME string
10591 // in an autosave allowlist — not a value read. Removed entirely
10592 // rather than deleting just the three secret keys, so there is no
10593 // dead payload left for someone to re-arm.
10594 break;
10595 }
10596
10597 // Additional localization that was in the original code
10598 wp_localize_script('mxchat-admin-js', 'mxchatPromptsAdmin', array(
10599 'ajax_url' => admin_url('admin-ajax.php'),
10600 'prompts_setting_nonce' => wp_create_nonce('mxchat_prompts_setting_nonce'),
10601 ));
10602 }
10603
10604 public function mxchat_sanitize($input) {
10605 $new_input = array();
10606
10607 if (isset($input['api_key'])) {
10608 $new_input['api_key'] = sanitize_text_field($input['api_key']);
10609 }
10610
10611 if (isset($input['similarity_threshold'])) {
10612 $new_input['similarity_threshold'] = absint($input['similarity_threshold']); // Ensure it's an integer
10613 $new_input['similarity_threshold'] = min(max($new_input['similarity_threshold'], 20), 85); // Enforce range
10614 }
10615
10616 if (isset($input['rag_sources_limit'])) {
10617 $new_input['rag_sources_limit'] = absint($input['rag_sources_limit']); // Ensure it's an integer
10618 $new_input['rag_sources_limit'] = min(max($new_input['rag_sources_limit'], 3), 10); // Enforce range 3-10
10619 }
10620
10621 if (isset($input['rag_chunks_limit'])) {
10622 $new_input['rag_chunks_limit'] = absint($input['rag_chunks_limit']); // Ensure it's an integer
10623 $new_input['rag_chunks_limit'] = min(max($new_input['rag_chunks_limit'], 8), 20); // Enforce range 8-20
10624 }
10625
10626 if (isset($input['xai_api_key'])) {
10627 $new_input['xai_api_key'] = sanitize_text_field($input['xai_api_key']);
10628 }
10629
10630 if (isset($input['claude_api_key'])) {
10631 $new_input['claude_api_key'] = sanitize_text_field($input['claude_api_key']);
10632 }
10633
10634 if (isset($input['enable_streaming_toggle'])) {
10635 $new_input['enable_streaming_toggle'] = ($input['enable_streaming_toggle'] === 'on') ? 'on' : 'off';
10636 } else {
10637 // If checkbox not checked, it won't be in $input, so set to 'off'
10638 $new_input['enable_streaming_toggle'] = 'off';
10639 }
10640
10641 if (isset($input['enable_web_search'])) {
10642 $new_input['enable_web_search'] = ($input['enable_web_search'] === 'on') ? 'on' : 'off';
10643 } else {
10644 // If checkbox not checked, it won't be in $input, so set to 'off'
10645 $new_input['enable_web_search'] = 'off';
10646 }
10647
10648 if (isset($input['deepseek_api_key'])) {
10649 $new_input['deepseek_api_key'] = sanitize_text_field($input['deepseek_api_key']);
10650 }
10651
10652 if (isset($input['gemini_api_key'])) {
10653 $new_input['gemini_api_key'] = sanitize_text_field($input['gemini_api_key']);
10654 }
10655
10656 if (isset($input['enable_woocommerce_integration'])) {
10657 $new_input['enable_woocommerce_integration'] = $input['enable_woocommerce_integration'] === 'on' ? 'on' : 'off';
10658 }
10659
10660 if (isset($input['privacy_toggle'])) {
10661 $new_input['privacy_toggle'] = $input['privacy_toggle'];
10662 }
10663
10664 if (isset($input['complianz_toggle'])) {
10665 $new_input['complianz_toggle'] = $input['complianz_toggle'];
10666 }
10667
10668 // Handle custom privacy text input
10669 if (isset($input['privacy_text'])) {
10670 // Allow basic HTML for links
10671 $new_input['privacy_text'] = wp_kses_post($input['privacy_text']);
10672 }
10673
10674 if (isset($input['system_prompt_instructions'])) {
10675 $new_input['system_prompt_instructions'] = sanitize_textarea_field($input['system_prompt_instructions']);
10676 }
10677
10678 if (isset($input['mxchat_pro_email'])) {
10679 $new_input['mxchat_pro_email'] = sanitize_email($input['mxchat_pro_email']);
10680 }
10681
10682 if (isset($input['mxchat_activation_key'])) {
10683 $new_input['mxchat_activation_key'] = sanitize_text_field($input['mxchat_activation_key']);
10684 }
10685
10686 if (isset($input['append_to_body'])) {
10687 $new_input['append_to_body'] = $input['append_to_body'] === 'on' ? 'on' : 'off';
10688 }
10689
10690 // Post type visibility settings
10691 if (isset($input['post_type_visibility_mode'])) {
10692 $allowed_modes = array('all', 'include', 'exclude');
10693 $new_input['post_type_visibility_mode'] = in_array($input['post_type_visibility_mode'], $allowed_modes)
10694 ? $input['post_type_visibility_mode']
10695 : 'all';
10696 }
10697
10698 if (isset($input['post_type_visibility_list'])) {
10699 if (is_array($input['post_type_visibility_list'])) {
10700 $new_input['post_type_visibility_list'] = array_map('sanitize_key', $input['post_type_visibility_list']);
10701 } else {
10702 $new_input['post_type_visibility_list'] = array();
10703 }
10704 }
10705
10706 if (isset($input['contextual_awareness_toggle'])) {
10707 $new_input['contextual_awareness_toggle'] = $input['contextual_awareness_toggle'] === 'on' ? 'on' : 'off';
10708 }
10709
10710 if (isset($input['citation_links_toggle'])) {
10711 $new_input['citation_links_toggle'] = $input['citation_links_toggle'] === 'on' ? 'on' : 'off';
10712 }
10713
10714 if (isset($input['strip_unapproved_links_toggle'])) {
10715 $new_input['strip_unapproved_links_toggle'] = $input['strip_unapproved_links_toggle'] === 'on' ? 'on' : 'off';
10716 }
10717
10718 // Satisfaction rating prompt — defaults ON when unchecked (so first save
10719 // doesn't accidentally disable it). The form posts 'on' when checked;
10720 // unchecked checkboxes don't post a value at all, so we infer 'off' only
10721 // when the autosave/PHP request explicitly clears it via empty string.
10722 if (array_key_exists('satisfaction_rating_enabled', $input)) {
10723 $new_input['satisfaction_rating_enabled'] = $input['satisfaction_rating_enabled'] === 'on' ? 'on' : 'off';
10724 }
10725
10726 // Satisfaction rating customization (plan-141a12). Idle is clamped 5-600;
10727 // the 4 text strings are sanitized + capped server-side. Blank values are
10728 // preserved so the integrator falls back to translated defaults.
10729 if (array_key_exists('satisfaction_rating_idle_seconds', $input)) {
10730 $new_input['satisfaction_rating_idle_seconds'] = max(5, min(600, intval($input['satisfaction_rating_idle_seconds'])));
10731 }
10732
10733 // Max input length in characters (plan a3fae2 part C). 0 = unlimited (default,
10734 // preserves current behavior). Clamp to a sane ceiling so a typo can't lock out
10735 // all input. REQUIRED here — mxchat_sanitize() rebuilds the array from a whitelist,
10736 // so without this isset-handler the key is stripped on the next save of ANY field
10737 // (the 2c02ea global-rate-limit footgun).
10738 if (array_key_exists('max_input_length', $input)) {
10739 $new_input['max_input_length'] = max(0, min(100000, intval($input['max_input_length'])));
10740 }
10741 if (array_key_exists('satisfaction_rating_question', $input)) {
10742 $new_input['satisfaction_rating_question'] = mb_substr(sanitize_text_field($input['satisfaction_rating_question']), 0, 200);
10743 }
10744 if (array_key_exists('satisfaction_rating_thanks', $input)) {
10745 $new_input['satisfaction_rating_thanks'] = mb_substr(sanitize_text_field($input['satisfaction_rating_thanks']), 0, 300);
10746 }
10747 if (array_key_exists('satisfaction_rating_placeholder', $input)) {
10748 $new_input['satisfaction_rating_placeholder'] = mb_substr(sanitize_text_field($input['satisfaction_rating_placeholder']), 0, 200);
10749 }
10750 if (array_key_exists('satisfaction_rating_saved', $input)) {
10751 $new_input['satisfaction_rating_saved'] = mb_substr(sanitize_text_field($input['satisfaction_rating_saved']), 0, 200);
10752 }
10753
10754 if (isset($input['top_bar_title'])) {
10755 $new_input['top_bar_title'] = sanitize_text_field($input['top_bar_title']);
10756 }
10757
10758 if (isset($input['ai_agent_text'])) {
10759 $new_input['ai_agent_text'] = sanitize_text_field($input['ai_agent_text']);
10760 }
10761
10762 if (isset($input['enable_email_block'])) {
10763 $new_input['enable_email_block'] = sanitize_text_field($input['enable_email_block']);
10764 }
10765
10766 if (isset($input['email_blocker_header_content'])) {
10767 // wp_kses_post() allows standard HTML tags permitted by WordPress
10768 $new_input['email_blocker_header_content'] = wp_kses_post($input['email_blocker_header_content']);
10769 }
10770 if (isset($input['email_blocker_button_text'])) {
10771 $new_input['email_blocker_button_text'] = sanitize_text_field($input['email_blocker_button_text']);
10772 }
10773 // Sanitize name field toggle
10774 if (isset($input['enable_name_field'])) {
10775 $new_input['enable_name_field'] = ($input['enable_name_field'] === 'on') ? 'on' : 'off';
10776 } else {
10777 $new_input['enable_name_field'] = 'off';
10778 }
10779 // Sanitize name field placeholder
10780 if (isset($input['name_field_placeholder'])) {
10781 $new_input['name_field_placeholder'] = sanitize_text_field($input['name_field_placeholder']);
10782 }
10783 // Consent checkbox (b062c4). Both toggles default off — an unchecked box
10784 // posts nothing, so the else-branch is what makes unticking stick.
10785 if (isset($input['enable_consent_checkbox'])) {
10786 $new_input['enable_consent_checkbox'] = ($input['enable_consent_checkbox'] === 'on') ? 'on' : 'off';
10787 } else {
10788 $new_input['enable_consent_checkbox'] = 'off';
10789 }
10790 if (isset($input['consent_checkbox_label'])) {
10791 // Shared allowlist with the render path — see MxChat_Utils::sanitize_consent_label().
10792 $new_input['consent_checkbox_label'] = MxChat_Utils::sanitize_consent_label($input['consent_checkbox_label']);
10793 }
10794 if (isset($input['consent_checkbox_required'])) {
10795 $new_input['consent_checkbox_required'] = ($input['consent_checkbox_required'] === 'on') ? 'on' : 'off';
10796 } else {
10797 $new_input['consent_checkbox_required'] = 'off';
10798 }
10799 // Lead capture options (c0cfaf). Default OFF gets the else-branch so
10800 // unticking sticks; default ON deliberately has NO else — an absent key
10801 // must stay absent so the reader's ?? 'on' default applies, otherwise
10802 // every autosave of an unrelated field would silently flip the setting
10803 // off on installs that never touched it (the 2c02ea bug class).
10804 if (isset($input['lead_capture_logged_in_toggle'])) {
10805 $new_input['lead_capture_logged_in_toggle'] = ($input['lead_capture_logged_in_toggle'] === 'on') ? 'on' : 'off';
10806 } else {
10807 $new_input['lead_capture_logged_in_toggle'] = 'off';
10808 }
10809 if (isset($input['lead_capture_require_email_toggle'])) {
10810 $new_input['lead_capture_require_email_toggle'] = ($input['lead_capture_require_email_toggle'] === 'on') ? 'on' : 'off';
10811 }
10812 if (isset($input['intro_message'])) {
10813 $new_input['intro_message'] = wp_kses_post($input['intro_message']); // Use wp_kses_post instead
10814 }
10815
10816 if (isset($input['input_copy'])) {
10817 $new_input['input_copy'] = sanitize_text_field($input['input_copy']);
10818 }
10819
10820 if (isset($input['rate_limit_message'])) {
10821 $new_input['rate_limit_message'] = sanitize_text_field($input['rate_limit_message']);
10822 }
10823
10824 // Handle the new rate limits format
10825 if (isset($input['rate_limits']) && is_array($input['rate_limits'])) {
10826 $new_input['rate_limits'] = array();
10827 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
10828 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
10829
10830 foreach ($input['rate_limits'] as $role_id => $settings) {
10831 $new_input['rate_limits'][$role_id] = array();
10832
10833 // Sanitize limit
10834 if (isset($settings['limit'])) {
10835 $limit = sanitize_text_field($settings['limit']);
10836 // Accept presets, 'unlimited', the '__custom__' sentinel, OR any positive
10837 // integer (custom value) — mirrors the global branch (plan-2c02ea). Without
10838 // the custom path the per-role custom input was dropped and reset to the role
10839 // default on every save (plan-7e23e7).
10840 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
10841 $new_input['rate_limits'][$role_id]['limit'] = $limit;
10842 } else {
10843 $new_input['rate_limits'][$role_id]['limit'] = ($role_id === 'logged_out') ? '10' : '100'; // Default
10844 }
10845 }
10846
10847 // Preserve the per-role custom value (mirrors the global branch's limit_custom).
10848 if (isset($settings['limit_custom'])) {
10849 $new_input['rate_limits'][$role_id]['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $settings['limit_custom']);
10850 }
10851
10852 // Sanitize timeframe
10853 if (isset($settings['timeframe'])) {
10854 $timeframe = sanitize_text_field($settings['timeframe']);
10855 if (in_array($timeframe, $allowed_timeframes, true)) {
10856 $new_input['rate_limits'][$role_id]['timeframe'] = $timeframe;
10857 } else {
10858 $new_input['rate_limits'][$role_id]['timeframe'] = 'daily'; // Default
10859 }
10860 }
10861
10862 // Sanitize message
10863 if (isset($settings['message'])) {
10864 $new_input['rate_limits'][$role_id]['message'] = sanitize_textarea_field($settings['message']);
10865 }
10866 }
10867 }
10868
10869 // Handle the whole-chatbot global rate limit (plan-mxchat-20260603-2c02ea).
10870 // Mirrors the per-role block above, adapted to the single global shape. Without
10871 // this branch the whitelist-rebuild dropped rate_limits_global entirely on every
10872 // save, so the global cap silently fell back to its 'unlimited' default.
10873 // MUST accept arbitrary positive integers so the custom-value path (d55f65) is not regressed.
10874 if (isset($input['rate_limits_global']) && is_array($input['rate_limits_global'])) {
10875 $g = $input['rate_limits_global'];
10876 $allowed_limits = array('1', '3', '5', '10', '15', '20', '50', '100', 'unlimited');
10877 $allowed_timeframes = array('hourly', 'daily', 'weekly', 'monthly');
10878 $global_out = array();
10879
10880 if (isset($g['limit'])) {
10881 $limit = sanitize_text_field($g['limit']);
10882 // Accept presets, 'unlimited', the '__custom__' sentinel (resolved by the
10883 // autosave handler / renderer), OR any positive integer (custom value).
10884 if (in_array($limit, $allowed_limits, true) || $limit === '__custom__' || (ctype_digit($limit) && (int) $limit >= 1)) {
10885 $global_out['limit'] = $limit;
10886 } else {
10887 $global_out['limit'] = 'unlimited';
10888 }
10889 }
10890
10891 if (isset($g['limit_custom'])) {
10892 $global_out['limit_custom'] = preg_replace('/[^0-9]/', '', (string) $g['limit_custom']);
10893 }
10894
10895 if (isset($g['timeframe'])) {
10896 $timeframe = sanitize_text_field($g['timeframe']);
10897 $global_out['timeframe'] = in_array($timeframe, $allowed_timeframes, true) ? $timeframe : 'daily';
10898 }
10899
10900 if (isset($g['message'])) {
10901 $global_out['message'] = sanitize_textarea_field($g['message']);
10902 }
10903
10904 $new_input['rate_limits_global'] = $global_out;
10905 }
10906
10907 if (isset($input['pre_chat_message'])) {
10908 $new_input['pre_chat_message'] = sanitize_textarea_field($input['pre_chat_message']);
10909 }
10910
10911 if (isset($input['voyage_api_key'])) {
10912 $new_input['voyage_api_key'] = sanitize_text_field($input['voyage_api_key']);
10913 }
10914
10915 // Add to your sanitize function
10916 if (isset($input['embedding_model'])) {
10917 // Catalog refactor (plan-d14e89): allowlist derived from the canonical
10918 // catalog in includes/class-mxchat-model-catalog.php.
10919 if (!class_exists('MxChat_Model_Catalog')) {
10920 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10921 }
10922 $allowed_models = MxChat_Model_Catalog::embedding_model_ids();
10923 if (in_array($input['embedding_model'], $allowed_models)) {
10924 $new_input['embedding_model'] = sanitize_text_field($input['embedding_model']);
10925 }
10926 }
10927
10928 if (isset($input['model'])) {
10929 if ($input['model'] === 'openrouter') {
10930 $new_input['model'] = 'openrouter';
10931 } else {
10932 if (!class_exists('MxChat_Model_Catalog')) {
10933 require_once plugin_dir_path(__FILE__) . 'class-mxchat-model-catalog.php';
10934 }
10935 $allowed_models = MxChat_Model_Catalog::chat_model_ids();
10936
10937 if (in_array($input['model'], $allowed_models)) {
10938 $new_input['model'] = sanitize_text_field($input['model']);
10939 } else {
10940 // Fallback for any deprecated model
10941 $new_input['model'] = 'gpt-5.6-sol';
10942 }
10943 }
10944 }
10945
10946 if (isset($input['openrouter_selected_model'])) {
10947 // Just sanitize it, don't validate against a whitelist
10948 $new_input['openrouter_selected_model'] = sanitize_text_field($input['openrouter_selected_model']);
10949 }
10950
10951 // ADD THIS:
10952 if (isset($input['openrouter_selected_model_name'])) {
10953 $new_input['openrouter_selected_model_name'] = sanitize_text_field($input['openrouter_selected_model_name']);
10954 }
10955
10956 if (isset($input['openrouter_api_key'])) {
10957 $new_input['openrouter_api_key'] = sanitize_text_field($input['openrouter_api_key']);
10958 }
10959
10960 // Custom (OpenAI-compatible) Provider — Ollama, LM Studio, vLLM, Azure OpenAI, etc.
10961 if (isset($input['custom_provider_base_url'])) {
10962 $new_input['custom_provider_base_url'] = esc_url_raw(rtrim(trim((string) $input['custom_provider_base_url']), '/'));
10963 }
10964 if (isset($input['custom_provider_api_key'])) {
10965 $new_input['custom_provider_api_key'] = sanitize_text_field($input['custom_provider_api_key']);
10966 }
10967 if (isset($input['custom_provider_model'])) {
10968 $new_input['custom_provider_model'] = sanitize_text_field($input['custom_provider_model']);
10969 }
10970 if (isset($input['custom_provider_auth_scheme'])) {
10971 $scheme = sanitize_text_field($input['custom_provider_auth_scheme']);
10972 $new_input['custom_provider_auth_scheme'] = in_array($scheme, array('bearer', 'api-key'), true) ? $scheme : 'bearer';
10973 }
10974 if (isset($input['custom_provider_for_embeddings'])) {
10975 $new_input['custom_provider_for_embeddings'] = ($input['custom_provider_for_embeddings'] === 'on') ? 'on' : 'off';
10976 }
10977 if (isset($input['custom_provider_for_images'])) {
10978 $new_input['custom_provider_for_images'] = ($input['custom_provider_for_images'] === 'on') ? 'on' : 'off';
10979 }
10980 if (isset($input['custom_provider_embedding_model'])) {
10981 $new_input['custom_provider_embedding_model'] = sanitize_text_field($input['custom_provider_embedding_model']);
10982 }
10983 if (isset($input['custom_provider_api_version'])) {
10984 $new_input['custom_provider_api_version'] = sanitize_text_field($input['custom_provider_api_version']);
10985 }
10986
10987
10988
10989 if (isset($input['woocommerce_consumer_key'])) {
10990 $new_input['woocommerce_consumer_key'] = sanitize_text_field($input['woocommerce_consumer_key']);
10991 }
10992
10993 if (isset($input['woocommerce_consumer_secret'])) {
10994 $new_input['woocommerce_consumer_secret'] = sanitize_text_field($input['woocommerce_consumer_secret']);
10995 }
10996
10997
10998 // Sanitize link_target_toggle
10999 if (isset($input['link_target_toggle'])) {
11000 $new_input['link_target_toggle'] = $input['link_target_toggle'] === 'on' ? 'on' : 'off';
11001 }
11002
11003 // Sanitize Loops API Key
11004 if (isset($input['loops_api_key'])) {
11005 $new_input['loops_api_key'] = sanitize_text_field($input['loops_api_key']);
11006 }
11007
11008 if (isset($input['chat_persistence_toggle'])) {
11009 $new_input['chat_persistence_toggle'] = $input['chat_persistence_toggle'] === 'on' ? 'on' : 'off';
11010 }
11011
11012 // No else clause: an absent key stays absent, so the front-end default ('on') applies
11013 // and the rebuild never strips a saved 'off' (autosave passes the full options array back through here).
11014 if (isset($input['print_button_enabled'])) {
11015 $new_input['print_button_enabled'] = $input['print_button_enabled'] === 'on' ? 'on' : 'off';
11016 }
11017
11018 // plan ac2e81 — "Start new chat" toggle (default OFF) + editable label.
11019 // No else on the toggle: an absent key stays absent so the front-end '?? off'
11020 // default applies, and the full-array autosave rebuild preserves a saved value.
11021 if (isset($input['reset_chat_enabled'])) {
11022 $new_input['reset_chat_enabled'] = $input['reset_chat_enabled'] === 'on' ? 'on' : 'off';
11023 }
11024
11025 if (isset($input['reset_chat_label'])) {
11026 $new_input['reset_chat_label'] = sanitize_text_field($input['reset_chat_label']);
11027 }
11028
11029 if (isset($input['popular_question_1'])) {
11030 $new_input['popular_question_1'] = sanitize_text_field($input['popular_question_1']);
11031 }
11032
11033 if (isset($input['popular_question_2'])) {
11034 $new_input['popular_question_2'] = sanitize_text_field($input['popular_question_2']);
11035 }
11036
11037 if (isset($input['popular_question_3'])) {
11038 $new_input['popular_question_3'] = sanitize_text_field($input['popular_question_3']);
11039 }
11040
11041 if (isset($input['additional_popular_questions']) && is_array($input['additional_popular_questions'])) {
11042 $new_input['additional_popular_questions'] = array_map('sanitize_text_field', $input['additional_popular_questions']);
11043 }
11044
11045 // Sanitize Loops Mailing List
11046 if (isset($input['loops_mailing_list'])) {
11047 $new_input['loops_mailing_list'] = sanitize_text_field($input['loops_mailing_list']);
11048 }
11049
11050 // Sanitize Triggered Phrase Response
11051 if (isset($input['triggered_phrase_response'])) {
11052 $new_input['triggered_phrase_response'] = wp_kses_post($input['triggered_phrase_response']);
11053 }
11054 if (isset($input['email_capture_response'])) {
11055 $new_input['email_capture_response'] = wp_kses_post($input['email_capture_response']);
11056 }
11057
11058 // Sanitize Brave Search Settings
11059 if (isset($input['brave_api_key'])) {
11060 $new_input['brave_api_key'] = sanitize_text_field($input['brave_api_key']);
11061 }
11062
11063 if (isset($input['brave_image_count'])) {
11064 $image_count = intval($input['brave_image_count']);
11065 $new_input['brave_image_count'] = ($image_count >=1 && $image_count <=6) ? $image_count : 4;
11066 }
11067
11068 if (isset($input['brave_safe_search'])) {
11069 $allowed = array('strict', 'off');
11070 $new_input['brave_safe_search'] = in_array($input['brave_safe_search'], $allowed, true) ? $input['brave_safe_search'] : 'strict';
11071 }
11072
11073 if (isset($input['brave_news_count'])) {
11074 $news_count = intval($input['brave_news_count']);
11075 $new_input['brave_news_count'] = ($news_count >=1 && $news_count <=10) ? $news_count : 3;
11076 }
11077
11078 if (isset($input['brave_country'])) {
11079 $new_input['brave_country'] = sanitize_text_field($input['brave_country']);
11080 }
11081
11082 if (isset($input['brave_language'])) {
11083 $new_input['brave_language'] = sanitize_text_field($input['brave_language']);
11084 }
11085
11086 if (isset($input['chat_toolbar_toggle'])) {
11087 $new_input['chat_toolbar_toggle'] = $input['chat_toolbar_toggle'] === 'on' ? 'on' : 'off';
11088 }
11089
11090 // Sanitize PDF upload button toggle
11091 if (isset($input['show_pdf_upload_button'])) {
11092 $new_input['show_pdf_upload_button'] = $input['show_pdf_upload_button'] === 'on' ? 'on' : 'off';
11093 } else {
11094 $new_input['show_pdf_upload_button'] = 'off'; // If checkbox is unchecked
11095 }
11096
11097 // Sanitize Word upload button toggle
11098 if (isset($input['show_word_upload_button'])) {
11099 $new_input['show_word_upload_button'] = $input['show_word_upload_button'] === 'on' ? 'on' : 'off';
11100 } else {
11101 $new_input['show_word_upload_button'] = 'off'; // If checkbox is unchecked
11102 }
11103
11104 if (isset($input['pdf_intent_trigger_text'])) {
11105 $new_input['pdf_intent_trigger_text'] = sanitize_text_field($input['pdf_intent_trigger_text']);
11106 }
11107
11108 if (isset($input['pdf_intent_success_text'])) {
11109 $new_input['pdf_intent_success_text'] = sanitize_text_field($input['pdf_intent_success_text']);
11110 }
11111
11112 if (isset($input['pdf_intent_error_text'])) {
11113 $new_input['pdf_intent_error_text'] = sanitize_text_field($input['pdf_intent_error_text']);
11114 }
11115
11116 if (isset($input['pdf_max_pages'])) {
11117 $new_input['pdf_max_pages'] = intval($input['pdf_max_pages']);
11118 if ($new_input['pdf_max_pages'] < 1 || $new_input['pdf_max_pages'] > 69) {
11119 $new_input['pdf_max_pages'] = 69; // Default to 69 if out of range
11120 }
11121 }
11122
11123 if (isset($input['live_agent_webhook_url'])) {
11124 $new_input['live_agent_webhook_url'] = esc_url_raw($input['live_agent_webhook_url']);
11125 }
11126 if (isset($input['live_agent_secret_key'])) {
11127 $new_input['live_agent_secret_key'] = sanitize_text_field($input['live_agent_secret_key']);
11128 }
11129
11130 // Live Agent Integration
11131 if (isset($input['live_agent_bot_token'])) {
11132 $new_input['live_agent_bot_token'] = sanitize_text_field($input['live_agent_bot_token']);
11133 }
11134
11135 if (isset($input['live_agent_shared_channel'])) {
11136 $new_input['live_agent_shared_channel'] = sanitize_text_field($input['live_agent_shared_channel']);
11137 }
11138
11139 // Default-OFF toggle: absent key stays absent (unchecked box = off).
11140 if (isset($input['live_agent_archive_on_end_toggle'])) {
11141 $new_input['live_agent_archive_on_end_toggle'] = ($input['live_agent_archive_on_end_toggle'] === 'on') ? 'on' : 'off';
11142 }
11143
11144 if (isset($input['live_agent_user_ids'])) {
11145 $new_input['live_agent_user_ids'] = sanitize_textarea_field($input['live_agent_user_ids']);
11146 }
11147
11148 if (isset($input['live_agent_status'])) {
11149 $new_input['live_agent_status'] = ($input['live_agent_status'] === 'on') ? 'on' : 'off';
11150 }
11151 if (isset($input['live_agent_away_message'])) {
11152 $new_input['live_agent_away_message'] = sanitize_textarea_field($input['live_agent_away_message']);
11153 }
11154 if (isset($input['live_agent_notification_message'])) {
11155 $new_input['live_agent_notification_message'] = sanitize_textarea_field($input['live_agent_notification_message']);
11156 }
11157
11158 // Telegram Integration
11159 if (isset($input['telegram_status'])) {
11160 $new_input['telegram_status'] = ($input['telegram_status'] === 'on') ? 'on' : 'off';
11161 }
11162 if (isset($input['telegram_bot_token'])) {
11163 $new_input['telegram_bot_token'] = sanitize_text_field($input['telegram_bot_token']);
11164 }
11165 if (isset($input['telegram_group_id'])) {
11166 $new_input['telegram_group_id'] = sanitize_text_field($input['telegram_group_id']);
11167 }
11168 if (isset($input['telegram_webhook_secret'])) {
11169 $new_input['telegram_webhook_secret'] = sanitize_text_field($input['telegram_webhook_secret']);
11170 }
11171 if (isset($input['telegram_notification_message'])) {
11172 $new_input['telegram_notification_message'] = sanitize_textarea_field($input['telegram_notification_message']);
11173 }
11174 if (isset($input['telegram_away_message'])) {
11175 $new_input['telegram_away_message'] = sanitize_textarea_field($input['telegram_away_message']);
11176 }
11177
11178 // Webhook Handoff (plan d88e22)
11179 if (isset($input['webhook_handoff_status'])) {
11180 $new_input['webhook_handoff_status'] = ($input['webhook_handoff_status'] === 'on') ? 'on' : 'off';
11181 }
11182 if (isset($input['webhook_handoff_url'])) {
11183 // https only — esc_url_raw with an https-only protocol list stores ''
11184 // for anything else, so a non-https URL can never persist.
11185 $new_input['webhook_handoff_url'] = esc_url_raw(trim((string) $input['webhook_handoff_url']), array('https'));
11186 }
11187 if (isset($input['webhook_handoff_secret'])) {
11188 $new_input['webhook_handoff_secret'] = sanitize_text_field($input['webhook_handoff_secret']);
11189 }
11190 if (isset($input['webhook_handoff_notification_message'])) {
11191 $new_input['webhook_handoff_notification_message'] = sanitize_textarea_field($input['webhook_handoff_notification_message']);
11192 }
11193 if (isset($input['webhook_handoff_away_message'])) {
11194 $new_input['webhook_handoff_away_message'] = sanitize_textarea_field($input['webhook_handoff_away_message']);
11195 }
11196
11197 // Sanitize script loading strategy
11198 if (isset($input['script_loading_strategy'])) {
11199 $allowed_strategies = array('default', 'defer', 'delay_1s', 'delay_3s', 'delay_5s', 'on_interaction');
11200 $new_input['script_loading_strategy'] = in_array($input['script_loading_strategy'], $allowed_strategies)
11201 ? $input['script_loading_strategy']
11202 : 'default';
11203 }
11204
11205 // Sanitize debug mode
11206 if (isset($input['debug_mode'])) {
11207 $new_input['debug_mode'] = ($input['debug_mode'] === 'on') ? 'on' : 'off';
11208 }
11209
11210 // Content Generator Settings
11211 if (isset($input['content_model'])) {
11212 $new_input['content_model'] = sanitize_text_field($input['content_model']);
11213 }
11214 if (isset($input['content_image_model'])) {
11215 $new_input['content_image_model'] = sanitize_text_field($input['content_image_model']);
11216 }
11217 if (isset($input['content_image_quality'])) {
11218 $q = sanitize_text_field($input['content_image_quality']);
11219 $new_input['content_image_quality'] = in_array($q, array('auto', 'low', 'medium', 'high'), true) ? $q : 'auto';
11220 }
11221 if (isset($input['content_enable_images'])) {
11222 $new_input['content_enable_images'] = ($input['content_enable_images'] === 'on') ? 'on' : 'off';
11223 }
11224 if (isset($input['content_use_placeholders'])) {
11225 $new_input['content_use_placeholders'] = ($input['content_use_placeholders'] === 'on') ? 'on' : 'off';
11226 }
11227 if (isset($input['content_internal_linking'])) {
11228 $new_input['content_internal_linking'] = ($input['content_internal_linking'] === 'on') ? 'on' : 'off';
11229 }
11230 if (isset($input['content_tool_use'])) {
11231 $new_input['content_tool_use'] = ($input['content_tool_use'] === 'on') ? 'on' : 'off';
11232 }
11233 if (isset($input['content_image_count'])) {
11234 $new_input['content_image_count'] = (string) max(1, min(5, (int) $input['content_image_count']));
11235 }
11236
11237 // SEO Optimize toggle fields
11238 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) {
11239 if (isset($input[$seo_key])) {
11240 $new_input[$seo_key] = ($input[$seo_key] === 'on') ? 'on' : 'off';
11241 }
11242 }
11243
11244 // Preserve content generator settings when saving from main settings page
11245 // (where content fields are not in the form submission)
11246 $existing = get_option('mxchat_options', array());
11247 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) {
11248 if (!isset($new_input[$key]) && isset($existing[$key])) {
11249 $new_input[$key] = $existing[$key];
11250 }
11251 }
11252
11253 return $new_input;
11254 }
11255
11256 /**
11257 * Log a debug message if debug mode is enabled
11258 *
11259 * @param string $type The type of log entry (settings_save, api_error, activation, etc.)
11260 * @param string $message The log message
11261 * @param array $data Optional additional data to log
11262 * @return bool Whether the message was logged
11263 */
11264 public static function mxchat_log_debug( $type, $message, $data = array() ) {
11265 $options = get_option( 'mxchat_options', array() );
11266
11267 // Check if debug mode is enabled
11268 if ( ! isset( $options['debug_mode'] ) || $options['debug_mode'] !== 'on' ) {
11269 return false;
11270 }
11271
11272 // Get current log
11273 $log = get_option( 'mxchat_debug_log', array() );
11274 if ( ! is_array( $log ) ) {
11275 $log = array();
11276 }
11277
11278 // Add new entry
11279 $entry = array(
11280 'time' => current_time( 'Y-m-d H:i:s' ),
11281 'type' => sanitize_key( $type ),
11282 'message' => sanitize_text_field( $message ),
11283 );
11284
11285 if ( ! empty( $data ) ) {
11286 $entry['data'] = $data;
11287 }
11288
11289 // Add to beginning of array (newest first)
11290 array_unshift( $log, $entry );
11291
11292 // Keep only last 100 entries
11293 if ( count( $log ) > 100 ) {
11294 $log = array_slice( $log, 0, 100 );
11295 }
11296
11297 // Save log
11298 update_option( 'mxchat_debug_log', $log, false );
11299
11300 return true;
11301 }
11302
11303 /**
11304 * Get the debug log entries
11305 *
11306 * @return array The debug log entries
11307 */
11308 public static function mxchat_get_debug_log() {
11309 $log = get_option( 'mxchat_debug_log', array() );
11310 return is_array( $log ) ? $log : array();
11311 }
11312
11313 /**
11314 * Clear the debug log
11315 *
11316 * @return bool Whether the log was cleared
11317 */
11318 public static function mxchat_clear_debug_log() {
11319 return delete_option( 'mxchat_debug_log' );
11320 }
11321
11322 /**
11323 * Export settings as JSON with masked API keys
11324 *
11325 * @return array The sanitized settings array
11326 */
11327 public static function mxchat_export_settings() {
11328 $options = get_option( 'mxchat_options', array() );
11329
11330 if ( ! is_array( $options ) ) {
11331 return array();
11332 }
11333
11334 // List of API key fields to mask
11335 $api_key_fields = array(
11336 'api_key',
11337 'xai_api_key',
11338 'claude_api_key',
11339 'deepseek_api_key',
11340 'voyage_api_key',
11341 'gemini_api_key',
11342 'openrouter_api_key',
11343 'loops_api_key',
11344 'brave_api_key',
11345 'live_agent_secret_key',
11346 'live_agent_bot_token',
11347 'telegram_bot_token',
11348 'telegram_webhook_secret',
11349 'woocommerce_consumer_key',
11350 'woocommerce_consumer_secret',
11351 );
11352
11353 // Mask API keys (show only last 4 characters)
11354 foreach ( $api_key_fields as $field ) {
11355 if ( isset( $options[ $field ] ) && ! empty( $options[ $field ] ) ) {
11356 $value = $options[ $field ];
11357 if ( strlen( $value ) > 4 ) {
11358 $options[ $field ] = str_repeat( '*', strlen( $value ) - 4 ) . substr( $value, -4 );
11359 } else {
11360 $options[ $field ] = '****';
11361 }
11362 }
11363 }
11364
11365 // Add metadata
11366 $export = array(
11367 'plugin_version' => defined( 'MXCHAT_VERSION' ) ? MXCHAT_VERSION : 'unknown',
11368 'export_date' => current_time( 'Y-m-d H:i:s' ),
11369 'wordpress_version' => get_bloginfo( 'version' ),
11370 'php_version' => phpversion(),
11371 'settings' => $options,
11372 );
11373
11374 return $export;
11375 }
11376
11377 /**
11378 * Reset all settings to defaults
11379 *
11380 * @return bool Whether the reset was successful
11381 */
11382 public static function mxchat_reset_all_settings() {
11383 // Delete main options
11384 $deleted = delete_option( 'mxchat_options' );
11385
11386 // Also clear the debug log
11387 delete_option( 'mxchat_debug_log' );
11388
11389 // Log the reset (will create new log since we just cleared it)
11390 // We need to temporarily enable debug mode to log this
11391 $temp_options = array( 'debug_mode' => 'on' );
11392 update_option( 'mxchat_options', $temp_options );
11393
11394 self::mxchat_log_debug( 'reset', 'All settings have been reset to defaults' );
11395
11396 // Now delete again to trigger re-initialization
11397 delete_option( 'mxchat_options' );
11398
11399 return $deleted;
11400 }
11401
11402 // Method to append the chatbot to the body
11403 public function mxchat_append_chatbot_to_body() {
11404 $options = get_option('mxchat_options');
11405 if (isset($options['append_to_body']) && $options['append_to_body'] === 'on') {
11406 echo do_shortcode('[mxchat_chatbot floating="yes"]');
11407 }
11408 }
11409
11410
11411
11412
11413
11414 private function mxchat_fetch_loops_mailing_lists($api_key) {
11415 $url = 'https://app.loops.so/api/v1/lists';
11416 $response = wp_remote_get($url, array(
11417 'headers' => array(
11418 'Authorization' => 'Bearer ' . $api_key,
11419 'Content-Type' => 'application/json'
11420 )
11421 ));
11422
11423 if (is_wp_error($response)) {
11424 return array();
11425 }
11426
11427 $body = wp_remote_retrieve_body($response);
11428 $lists = json_decode($body, true);
11429
11430 return isset($lists) && is_array($lists) ? $lists : array();
11431 }
11432
11433 function mxchat_calculate_cosine_similarity($vec1, $vec2) {
11434 if (empty($vec1) || empty($vec2)) {
11435 return 0.0;
11436 }
11437
11438 $dot_product = 0.0;
11439 $norm_a = 0.0;
11440 $norm_b = 0.0;
11441
11442 for ($i = 0; $i < count($vec1); $i++) {
11443 $dot_product += $vec1[$i] * $vec2[$i];
11444 $norm_a += pow($vec1[$i], 2);
11445 $norm_b += pow($vec2[$i], 2);
11446 }
11447
11448 if ($norm_a == 0.0 || $norm_b == 0.0) {
11449 return 0.0;
11450 } else {
11451 return $dot_product / (sqrt($norm_a) * sqrt($norm_b));
11452 }
11453 }
11454
11455 /**
11456 * Validates nonce and deletes all chat prompts
11457 */
11458 public function mxchat_handle_delete_all_prompts() {
11459 //error_log('=== DELETE ALL DEBUG START ===');
11460
11461 // Verify nonce
11462 if (!isset($_POST['mxchat_delete_all_prompts_nonce']) || !wp_verify_nonce($_POST['mxchat_delete_all_prompts_nonce'], 'mxchat_delete_all_prompts_action')) {
11463 //error_log('DEBUG: Nonce verification failed');
11464 wp_die(__('Nonce verification failed.', 'mxchat'));
11465 }
11466
11467 // Check permissions
11468 if (!current_user_can('manage_options')) {
11469 //error_log('DEBUG: Permission check failed');
11470 wp_die(__('You do not have sufficient permissions to delete all prompts.', 'mxchat'));
11471 }
11472
11473 // Get bot_id and content type filter from POST data
11474 $bot_id = isset($_POST['bot_id']) ? sanitize_text_field($_POST['bot_id']) : 'default';
11475 $content_type_filter = isset($_POST['content_type_filter']) ? sanitize_text_field($_POST['content_type_filter']) : '';
11476
11477 $success = true;
11478 $error_messages = array();
11479
11480 // Get bot-specific Pinecone configuration
11481 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
11482 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
11483
11484 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
11485
11486 if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) {
11487 // Delete from Pinecone (with optional content type filter)
11488 $result = $pinecone_manager->mxchat_delete_all_from_pinecone($pinecone_options, $content_type_filter);
11489
11490 if (!$result['success']) {
11491 $success = false;
11492 $error_messages[] = $result['message'];
11493 }
11494
11495 } else {
11496 // Delete from WordPress database
11497 global $wpdb;
11498 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
11499
11500 // Build WHERE conditions
11501 $where_clauses = array();
11502 $where_values = array();
11503
11504 // Bot filter
11505 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
11506 $where_clauses[] = 'bot_id = %s';
11507 $where_values[] = $bot_id;
11508 }
11509
11510 // Content type filter
11511 if (!empty($content_type_filter)) {
11512 $where_clauses[] = 'content_type = %s';
11513 $where_values[] = $content_type_filter;
11514 }
11515
11516 if (!empty($where_clauses)) {
11517 $where_sql = implode(' AND ', $where_clauses);
11518 $result = $wpdb->query($wpdb->prepare("DELETE FROM {$table_name} WHERE {$where_sql}", $where_values));
11519 } else {
11520 // No filters — delete all
11521 $result = $wpdb->query("DELETE FROM {$table_name}");
11522 }
11523
11524 if ($result === false) {
11525 $success = false;
11526 $error_messages[] = 'Failed to delete from WordPress database';
11527 }
11528 }
11529
11530 // Redirect back with a success message and bot_id
11531 $redirect_url = add_query_arg(array(
11532 'page' => 'mxchat-prompts',
11533 'bot_id' => $bot_id,
11534 'all_deleted' => $success ? 'true' : 'false'
11535 ), admin_url('admin.php'));
11536
11537 wp_safe_redirect($redirect_url);
11538 exit;
11539 }
11540
11541
11542
11543 /**
11544 * Handles deletion prompt with nonce validation
11545 */
11546 public function mxchat_handle_delete_prompt() {
11547 // Sanitize and validate nonce
11548 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
11549 if (empty($nonce) || !wp_verify_nonce($nonce, 'mxchat_delete_prompt_nonce')) {
11550 wp_die(esc_html__('Nonce verification failed.', 'mxchat'));
11551 }
11552
11553 // Check permissions
11554 if (!current_user_can('manage_options')) {
11555 wp_die(esc_html__('You do not have sufficient permissions to delete prompts.', 'mxchat'));
11556 }
11557
11558 // Get ID and source parameters
11559 $id = isset($_GET['id']) ? sanitize_text_field($_GET['id']) : '';
11560 $source = isset($_GET['source']) ? sanitize_text_field($_GET['source']) : '';
11561
11562 if (empty($id)) {
11563 wp_die(esc_html__('Invalid prompt ID.', 'mxchat'));
11564 }
11565
11566 $success = false;
11567 $error_message = '';
11568
11569 // Resolve per-bot Pinecone settings — the global option is the wrong
11570 // index entirely when the row belongs to a bot with its own host
11571 $bot_id = isset($_GET['bot_id']) ? sanitize_text_field($_GET['bot_id']) : 'default';
11572 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
11573 $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options($bot_id);
11574 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
11575
11576 // If source is not specified, determine based on Pinecone configuration
11577 if (empty($source)) {
11578 $source = $use_pinecone ? 'pinecone' : 'wordpress';
11579 }
11580
11581 if ($source === 'pinecone' || $use_pinecone) {
11582 //error_log('[MXCHAT-DELETE] Deleting from Pinecone, ID: ' . $id);
11583
11584 // Handle Pinecone deletion
11585 if (empty($pinecone_options['mxchat_pinecone_host']) ||
11586 empty($pinecone_options['mxchat_pinecone_api_key'])) {
11587 wp_die(esc_html__('Pinecone configuration is missing.', 'mxchat'));
11588 }
11589
11590 // Delete from Pinecone using the vector ID directly
11591 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
11592 $id,
11593 $pinecone_options['mxchat_pinecone_api_key'],
11594 $pinecone_options['mxchat_pinecone_host'],
11595 $pinecone_options['mxchat_pinecone_namespace'] ?? ''
11596 );
11597 if ($result['success']) {
11598 $success = true;
11599
11600 // Remove from vector cache
11601 $pinecone_manager->mxchat_remove_from_pinecone_vector_cache($id);
11602 $pinecone_manager->mxchat_remove_from_processed_content_caches($id);
11603
11604 set_transient('mxchat_admin_notice_success',
11605 esc_html__('Vector deleted successfully from Pinecone.', 'mxchat'), 30);
11606 } else {
11607 $error_message = $result['message'];
11608 set_transient('mxchat_admin_notice_error',
11609 esc_html__('Failed to delete from Pinecone: ', 'mxchat') . esc_html($error_message), 30);
11610 }
11611 } else {
11612 //error_log('[MXCHAT-DELETE] Deleting from WordPress database, ID: ' . $id);
11613
11614 // Handle WordPress database deletion
11615 global $wpdb;
11616 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
11617
11618 // Clear cache and delete prompt
11619 wp_cache_delete('prompt_' . $id, 'mxchat_prompts');
11620
11621 $result = $wpdb->delete(
11622 $table_name,
11623 array('id' => intval($id)),
11624 array('%d')
11625 );
11626
11627 if ($result !== false) {
11628 $success = true;
11629 set_transient('mxchat_admin_notice_success',
11630 esc_html__('Entry deleted successfully.', 'mxchat'), 30);
11631 } else {
11632 set_transient('mxchat_admin_notice_error',
11633 esc_html__('Failed to delete entry from database.', 'mxchat'), 30);
11634 }
11635 }
11636
11637 // Redirect back to the prompts page
11638 wp_safe_redirect(add_query_arg(
11639 array(
11640 'page' => 'mxchat-prompts',
11641 'deleted' => $success ? 'true' : 'false'
11642 ),
11643 admin_url('admin.php')
11644 ));
11645 exit;
11646 }
11647
11648
11649
11650
11651 /**
11652 * Averages multiple vectors into a single vector
11653 */
11654 private function mxchat_average_vectors($vectors) {
11655 $vector_length = count($vectors[0]);
11656 $sum_vector = array_fill(0, $vector_length, 0);
11657
11658 foreach ($vectors as $vector) {
11659 for ($i = 0; $i < $vector_length; $i++) {
11660 $sum_vector[$i] += $vector[$i];
11661 }
11662 }
11663
11664 // Divide each component by the number of vectors to get the average
11665 $num_vectors = count($vectors);
11666 for ($i = 0; $i < $vector_length; $i++) {
11667 $sum_vector[$i] /= $num_vectors;
11668 }
11669
11670 return $sum_vector;
11671 }
11672
11673
11674
11675
11676 /**
11677 * Generates embeddings from input text for MXChat
11678 */
11679 public function mxchat_generate_embedding($text) {
11680 // Enable detailed logging for debugging
11681 //error_log('[MXCHAT-EMBED] Starting embedding generation. Text length: ' . strlen($text) . ' bytes');
11682 //error_log('[MXCHAT-EMBED] Text preview: ' . substr($text, 0, 100) . '...');
11683
11684 $options = get_option('mxchat_options');
11685 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
11686 //error_log('[MXCHAT-EMBED] Selected embedding model: ' . $selected_model);
11687
11688 // Determine provider and endpoint
11689 if (strpos($selected_model, 'voyage') === 0) {
11690 $api_key = $options['voyage_api_key'] ?? '';
11691 $endpoint = 'https://api.voyageai.com/v1/embeddings';
11692 $provider_name = 'Voyage AI';
11693 //error_log('[MXCHAT-EMBED] Using Voyage AI API');
11694 } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
11695 $api_key = $options['gemini_api_key'] ?? '';
11696 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
11697 $provider_name = 'Google Gemini';
11698 //error_log('[MXCHAT-EMBED] Using Google Gemini API');
11699 } else {
11700 $api_key = $options['api_key'] ?? '';
11701 $endpoint = 'https://api.openai.com/v1/embeddings';
11702 $provider_name = 'OpenAI';
11703 //error_log('[MXCHAT-EMBED] Using OpenAI API');
11704 }
11705
11706 //error_log('[MXCHAT-EMBED] Using endpoint: ' . $endpoint);
11707
11708 if (empty($api_key)) {
11709 $error_message = sprintf('Missing %s API key. Please configure your API key in the MxChat settings.', $provider_name);
11710 //error_log('[MXCHAT-EMBED] Error: ' . $error_message);
11711 return $error_message;
11712 }
11713
11714 // Check if text is too long (for OpenAI, roughly estimate tokens as words/0.75)
11715 $estimated_tokens = ceil(str_word_count($text) / 0.75);
11716 //error_log('[MXCHAT-EMBED] Estimated token count: ~' . $estimated_tokens);
11717
11718 if ($estimated_tokens > 8000 && strpos($selected_model, 'voyage') === false && strpos($selected_model, 'gemini-embedding') === false) {
11719 //error_log('[MXCHAT-EMBED] Warning: Text may exceed OpenAI token limits (8K for most models)');
11720 // Consider truncating text here
11721 }
11722
11723 // Prepare request body based on provider
11724 if (strpos($selected_model, 'gemini-embedding') === 0) {
11725 // Gemini API format
11726 $request_body = array(
11727 'model' => 'models/' . $selected_model,
11728 'content' => array(
11729 'parts' => array(
11730 array('text' => $text)
11731 )
11732 )
11733 );
11734
11735 // Set output dimensionality to 1536 for consistency with other models
11736 $request_body['outputDimensionality'] = 1536;
11737 } else {
11738 // OpenAI/Voyage API format
11739 $request_body = array(
11740 'model' => $selected_model,
11741 'input' => $text
11742 );
11743
11744 // Add output_dimension for voyage-3-large model
11745 if ($selected_model === 'voyage-3-large') {
11746 $request_body['output_dimension'] = 2048;
11747 }
11748 }
11749
11750 //error_log('[MXCHAT-EMBED] Request prepared with model: ' . $selected_model);
11751
11752 // Prepare headers based on provider
11753 if (strpos($selected_model, 'gemini-embedding') === 0) {
11754 // Gemini uses API key as query parameter
11755 $endpoint .= '?key=' . $api_key;
11756 $headers = array(
11757 'Content-Type' => 'application/json'
11758 );
11759 } else {
11760 // OpenAI/Voyage use Bearer token
11761 $headers = array(
11762 'Authorization' => 'Bearer ' . $api_key,
11763 'Content-Type' => 'application/json'
11764 );
11765 }
11766
11767 // Make API request
11768 //error_log('[MXCHAT-EMBED] Sending API request to: ' . $endpoint);
11769 $response = wp_remote_post($endpoint, array(
11770 'body' => wp_json_encode($request_body),
11771 'headers' => $headers,
11772 'timeout' => 60 // Increased timeout for large inputs
11773 ));
11774
11775 // Handle wp_remote_post errors
11776 if (is_wp_error($response)) {
11777 $error_message = $response->get_error_message();
11778 //error_log('[MXCHAT-EMBED] WP Remote Post Error: ' . $error_message);
11779 return 'Connection error: ' . $error_message;
11780 }
11781
11782 // Get and check HTTP response code
11783 $http_code = wp_remote_retrieve_response_code($response);
11784 //error_log('[MXCHAT-EMBED] API Response Code: ' . $http_code);
11785
11786 if ($http_code !== 200) {
11787 $error_body = wp_remote_retrieve_body($response);
11788 //error_log('[MXCHAT-EMBED] API Error Response Body: ' . $error_body);
11789
11790 // Try to parse error for more details
11791 $error_json = json_decode($error_body, true);
11792 if (json_last_error() === JSON_ERROR_NONE && isset($error_json['error'])) {
11793 $error_type = $error_json['error']['type'] ?? 'unknown';
11794 $error_message = $error_json['error']['message'] ?? 'No message';
11795 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
11796 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
11797
11798 // Customize error message for common API errors
11799 if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
11800 $error_message = sprintf('Invalid %s API key. Please check your API key in the MxChat settings.', $provider_name);
11801 } elseif ($error_type === 'authentication_error') {
11802 $error_message = sprintf('%s authentication failed. Please verify your API key in the MxChat settings.', $provider_name);
11803 }
11804
11805 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
11806 return $error_message;
11807 }
11808
11809 $error_message = sprintf("API Error (HTTP %d): Unable to generate embedding", $http_code);
11810 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
11811 return $error_message;
11812 }
11813
11814 // Parse response body
11815 $response_body = wp_remote_retrieve_body($response);
11816 //error_log('[MXCHAT-EMBED] Received response length: ' . strlen($response_body) . ' bytes');
11817
11818 $response_data = json_decode($response_body, true);
11819
11820 if (json_last_error() !== JSON_ERROR_NONE) {
11821 $error = json_last_error_msg();
11822 //error_log('[MXCHAT-EMBED] JSON Parse Error: ' . $error);
11823 //error_log('[MXCHAT-EMBED] Response preview: ' . substr($response_body, 0, 200));
11824 return "Failed to parse API response: $error";
11825 }
11826
11827 // Handle different response formats based on provider
11828 if (strpos($selected_model, 'gemini-embedding') === 0) {
11829 // Gemini API response format
11830 if (isset($response_data['embedding']['values'])) {
11831 $embedding_dimensions = count($response_data['embedding']['values']);
11832 //error_log('[MXCHAT-EMBED] Successfully extracted Gemini embedding with ' . $embedding_dimensions . ' dimensions');
11833
11834 // Check if embedding dimensions are as expected (should be 1536)
11835 if ($embedding_dimensions !== 1536) {
11836 //error_log('[MXCHAT-EMBED] Warning: Unexpected Gemini embedding dimensions: ' . $embedding_dimensions);
11837 }
11838
11839 MxChat_Utils::stamp_active_embedding_model($selected_model);
11840 return $response_data['embedding']['values'];
11841 } else {
11842 //error_log('[MXCHAT-EMBED] Error: No embedding found in Gemini response');
11843 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
11844
11845 if (isset($response_data['error'])) {
11846 $error_message = "Gemini API Error in response: " . wp_json_encode($response_data['error']);
11847 //error_log('[MXCHAT-EMBED] ' . $error_message);
11848 return $error_message;
11849 }
11850
11851 $error_message = "Invalid Gemini API response format: No embedding found";
11852 //error_log('[MXCHAT-EMBED] ' . $error_message);
11853 return $error_message;
11854 }
11855 } else {
11856 // OpenAI/Voyage API response format
11857 if (isset($response_data['data'][0]['embedding'])) {
11858 $embedding_dimensions = count($response_data['data'][0]['embedding']);
11859 //error_log('[MXCHAT-EMBED] Successfully extracted embedding with ' . $embedding_dimensions . ' dimensions');
11860
11861 // Check if embedding dimensions are as expected
11862 if (($selected_model === 'text-embedding-ada-002' && $embedding_dimensions !== 1536) ||
11863 ($selected_model === 'voyage-3-large' && $embedding_dimensions !== 2048)) {
11864 //error_log('[MXCHAT-EMBED] Warning: Unexpected embedding dimensions');
11865 }
11866
11867 MxChat_Utils::stamp_active_embedding_model($selected_model);
11868 return $response_data['data'][0]['embedding'];
11869 } else {
11870 //error_log('[MXCHAT-EMBED] Error: No embedding found in response');
11871 //error_log('[MXCHAT-EMBED] Response structure: ' . wp_json_encode(array_keys($response_data)));
11872
11873 if (isset($response_data['error'])) {
11874 $error_message = "API Error in response: " . wp_json_encode($response_data['error']);
11875 //error_log('[MXCHAT-EMBED] ' . $error_message);
11876 return $error_message;
11877 }
11878
11879 $error_message = "Invalid API response format: No embedding found";
11880 //error_log('[MXCHAT-EMBED] ' . $error_message);
11881 return $error_message;
11882 }
11883 }
11884 }
11885
11886
11887
11888 }
11889 ?>
11890