is_activated; } /** * Capability that opens the Transcripts screen read-only (plan 93999a). * * Defaults to the dedicated mxchat_view_transcripts capability, which * activation grants to administrators. A site that would rather reuse a * capability its staff already have can return it from the * mxchat_transcripts_capability filter. * * @return string */ public static function mxchat_transcripts_capability() { $cap = apply_filters('mxchat_transcripts_capability', 'mxchat_view_transcripts'); return (is_string($cap) && $cap !== '') ? $cap : 'mxchat_view_transcripts'; } /** * Whether the current user may READ transcripts: list, open, search and * export conversations and leads (plan 93999a). Administrators always can, * whether or not the capability was granted to their role. Every write on * the Transcripts screen (delete, translate, notification and retention * settings) keeps requiring manage_options. * * @return bool */ public static function mxchat_can_view_transcripts() { return current_user_can('manage_options') || current_user_can(self::mxchat_transcripts_capability()); } /** * woocommerce_prevent_admin_access: a user who holds the transcripts * capability reaches wp-admin, as they would on a site without WooCommerce * (plan 93999a). Every other user keeps WooCommerce's decision. * * @param bool $prevent * @return bool */ public function mxchat_let_transcripts_readers_into_admin($prevent) { if ($prevent && current_user_can(self::mxchat_transcripts_capability())) { return false; } return $prevent; } /** * Get plugin options * @return array */ public function get_options() { return $this->options; } public function __construct($knowledge_manager = null) { $this->options = get_option('mxchat_options'); $this->chat_count = get_option('mxchat_chat_count', 0); $this->is_activated = $this->is_license_active(); $this->knowledge_manager = $knowledge_manager; // Seed defaults on init, not here — the constructor runs on // plugins_loaded, before the mxchat textdomain loads (init, priority 10), // so translating the default strings here both triggers WP 6.7's // _load_textdomain_just_in_time notice and stores the untranslated // English fallbacks. Priority 20 lands after mxchat_load_textdomain(). if (!$this->options) { add_action('init', array($this, 'initialize_default_options'), 20); } // Add admin menu and initialize settings add_action('admin_menu', array($this, 'mxchat_add_plugin_page')); // Pro & Extensions registers at priority 30 so it always lands last in // the sidebar — below API Access (priority 20) and below any other // submenu that hooks at default priority 10. add_action('admin_menu', array($this, 'mxchat_add_pro_extensions_page'), 30); // Onboarding visibility — runs LAST so it can remove the submenu after every // other add_submenu_page() call. The page stays reachable by direct URL. add_action('admin_menu', array($this, 'mxchat_apply_onboarding_visibility'), 999); add_action('admin_init', array($this, 'mxchat_page_init')); add_action('admin_init', array($this, 'mxchat_prompts_page_init')); add_action('admin_enqueue_scripts', array($this, 'mxchat_enqueue_admin_assets')); // Add body class for the Onboarding wizard (plan-905439) so the // chrome-surgery CSS in admin-onboarding-wizard.css can scope its // WP-sidebar collapse to this page only. add_filter('admin_body_class', array($this, 'mxchat_add_onboarding_body_class')); add_action('wp_ajax_mxchat_delete_chat_history', array($this, 'mxchat_delete_chat_history')); add_action('admin_post_mxchat_delete_prompt', array($this, 'mxchat_handle_delete_prompt')); add_action('wp_ajax_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history')); add_action('wp_ajax_nopriv_mxchat_fetch_chat_history', array($this, 'mxchat_fetch_chat_history')); add_action('wp_ajax_mxchat_fetch_conversation', array($this, 'mxchat_fetch_conversation')); add_action('wp_footer', array($this, 'mxchat_append_chatbot_to_body')); add_action('admin_head-mxchat-prompts', array($this, 'mxchat_enqueue_admin_assets')); add_action('admin_head-toplevel_page_mxchat-max', array($this, 'mxchat_enqueue_admin_assets')); add_action('admin_notices', array($this, 'mxchat_display_admin_notice')); add_action('admin_post_mxchat_delete_all_prompts', array($this, 'mxchat_handle_delete_all_prompts')); add_action('admin_post_mxchat_add_intent', array($this, 'mxchat_handle_add_intent')); add_action('admin_post_mxchat_delete_intent', array($this, 'mxchat_handle_delete_intent')); add_action('admin_post_mxchat_edit_intent', array($this, 'mxchat_handle_edit_intent')); add_action('wp_ajax_mxchat_export_transcripts', array($this, 'export_chat_transcripts')); // Leads tab (inside Transcripts) add_action('wp_ajax_mxchat_fetch_leads', array($this, 'mxchat_fetch_leads')); add_action('wp_ajax_mxchat_delete_leads', array($this, 'mxchat_delete_leads')); add_action('wp_ajax_mxchat_export_leads', array($this, 'mxchat_export_leads')); add_action('admin_init', array($this, 'mxchat_transcripts_page_init')); // WooCommerce sends anyone without edit_posts from wp-admin to My // Account, which would lock the read-only transcripts role out of the // only screen it is for (plan 93999a). add_filter('woocommerce_prevent_admin_access', array($this, 'mxchat_let_transcripts_readers_into_admin')); add_action('wp_ajax_dismiss_live_agent_notice', array($this, 'dismiss_live_agent_notice')); add_action('wp_ajax_dismiss_theme_migration_notice', array($this, 'dismiss_theme_migration_notice')); add_action('mxchat_cleanup_old_transcripts', array($this, 'cleanup_old_transcripts')); // Self-heal: the cleanup event is otherwise only scheduled at activation or // when the retention setting CHANGES — if it is ever lost (deactivate cycle, // cron-option wipe, DB restore) nothing re-registers it and retention goes // silently inert while the UI still says it is on (plan-bc08a6). add_action('admin_init', array($this, 'ensure_transcript_cleanup_scheduled')); add_action('admin_init', array($this, 'register_pinecone_settings')); add_action('admin_init', array($this, 'register_openai_vectorstore_settings')); add_action('admin_notices', array($this, 'display_admin_notices')); add_action('wp_ajax_mxchat_test_vectorstore_connection', array($this, 'mxchat_test_vectorstore_connection')); // wp_ajax_mxchat_save_selected_bot is registered (and handled) in // admin/class-ajax-handler.php — a duplicate registration here pointed // at a method this class never defined, a load-order-dependent fatal // waiting to fire (plan 52bcad). add_action('wp_ajax_mxchat_fetch_openrouter_models', array($this, 'fetch_openrouter_models')); add_action('wp_ajax_mxchat_get_rag_context', array($this, 'mxchat_get_rag_context')); // Actions page AJAX handlers add_action('wp_ajax_mxchat_fetch_actions_list', array($this, 'mxchat_fetch_actions_list')); add_action('wp_ajax_mxchat_toggle_action_status', array($this, 'mxchat_toggle_action_status')); add_action('wp_ajax_mxchat_bulk_delete_actions', array($this, 'mxchat_bulk_delete_actions')); add_action('wp_ajax_mxchat_add_intent_ajax', array($this, 'mxchat_add_intent_ajax')); add_action('wp_ajax_mxchat_edit_intent_ajax', array($this, 'mxchat_edit_intent_ajax')); add_action('wp_ajax_mxchat_delete_intent_ajax', array($this, 'mxchat_delete_intent_ajax')); add_action('wp_ajax_mxchat_add_phrase', array($this, 'mxchat_add_phrase_ajax')); add_action('wp_ajax_mxchat_delete_phrase', array($this, 'mxchat_delete_phrase_ajax')); add_action('wp_ajax_mxchat_get_phrases', array($this, 'mxchat_get_phrases_ajax')); add_action('wp_ajax_mxchat_delete_legacy_phrases', array($this, 'mxchat_delete_legacy_phrases_ajax')); // Slack test connection add_action('wp_ajax_mxchat_test_slack_connection', array($this, 'mxchat_test_slack_connection')); // Translation handlers add_action('wp_ajax_mxchat_translate_messages', array($this, 'mxchat_translate_messages')); add_action('wp_ajax_mxchat_get_transcript_translation', array($this, 'mxchat_get_transcript_translation')); // 3.2.3: Embedding model switch protection add_action('wp_ajax_mxchat_check_embedding_switch', array($this, 'mxchat_check_embedding_switch_ajax')); add_action('wp_ajax_mxchat_dismiss_embedding_mismatch', array($this, 'mxchat_dismiss_embedding_mismatch_ajax')); add_action('wp_ajax_mxchat_dismiss_telegram_secret_notice', array($this, 'mxchat_dismiss_telegram_secret_notice_ajax')); add_action('admin_notices', array($this, 'mxchat_embedding_mismatch_notice')); add_action('admin_notices', array($this, 'mxchat_telegram_secret_notice')); } /** * Nudge admins to configure a Telegram webhook secret (plan-0c17b5). * * Shown only when a Telegram bot token is configured (integration active) * AND no webhook secret is set. Without a secret the webhook falls back to a * Telegram source-IP check — safer than the old fail-open, but a real secret * is the recommended protection. Dismissible; does not block anything. */ public function mxchat_telegram_secret_notice() { if (!current_user_can('manage_options')) { return; } $options = get_option('mxchat_options', array()); $has_token = !empty($options['telegram_bot_token']); $has_secret = !empty($options['telegram_webhook_secret']); if (!$has_token) { return; } // Inbound rejections recorded by verify_telegram_request() (plan 30398e). // When the webhook is actually being rejected this stops being a hardening // suggestion and becomes an outage report: it shows even when a secret IS // set (a wrong or unregistered secret rejects just as hard as none), and it // ignores the dismissal flag, because a nudge someone dismissed in July // must not silence live breakage. It self-clears on the first accepted // request, so a fixed install stops nagging without anyone touching it. $rejects = get_option('mxchat_telegram_webhook_rejects', array()); $reject_count = (is_array($rejects) && !empty($rejects['count'])) ? (int) $rejects['count'] : 0; if ($reject_count < 1) { // Nothing is failing — the original advisory, unchanged. if ($has_secret) { return; } if (get_option('mxchat_dismissed_telegram_secret_notice', '') === '1') { return; } } $settings_url = admin_url('admin.php?page=mxchat-settings#integrations-telegram'); ?> 0) : ?>

mxchat_telegram_reject_advice($rejects['last_reason'] ?? '')); ?>

$is_mismatch, 'active_model' => $active_model, 'active_label' => MxChat_Utils::embedding_model_label($active_model), 'new_model' => $new_model, 'new_label' => MxChat_Utils::embedding_model_label($new_model), 'dims_differ' => ($active_dims > 0 && $new_dims > 0 && $active_dims !== $new_dims), 'active_dims' => $active_dims, 'new_dims' => $new_dims, )); } /** * 3.2.3: Dismiss the persistent mismatch banner. Tied to the active+selected * pair so the banner reappears on the next switch event. */ public function mxchat_dismiss_embedding_mismatch_ajax() { check_ajax_referer('mxchat_admin_nonce', 'security'); if (!current_user_can('manage_options')) { wp_send_json_error(__('Unauthorized', 'mxchat')); } $options = get_option('mxchat_options', array()); $selected = MxChat_Utils::get_selected_embedding_model($options); $active = MxChat_Utils::get_active_embedding_model(); update_option('mxchat_dismissed_embedding_mismatch', $active . '|' . $selected, false); wp_send_json_success(); } /** * 3.2.3: Persistent admin banner shown whenever the active embedding model * (last used to actually embed something) differs from the currently * selected model. Pure option comparison — no DB queries on every page * load. The banner auto-clears once both match again, i.e. after a delete * + re-embed cycle. */ public function mxchat_embedding_mismatch_notice() { if (!current_user_can('manage_options')) { return; } $options = get_option('mxchat_options', array()); $selected = MxChat_Utils::get_selected_embedding_model($options); $active = MxChat_Utils::get_active_embedding_model(); if (empty($active) || empty($selected) || $active === $selected) { return; } $dismissed = get_option('mxchat_dismissed_embedding_mismatch', ''); if ($dismissed === $active . '|' . $selected) { return; } $active_label = MxChat_Utils::embedding_model_label($active); $selected_label = MxChat_Utils::embedding_model_label($selected); $active_dims = MxChat_Utils::embedding_model_dimensions($active); $selected_dims = MxChat_Utils::embedding_model_dimensions($selected); $dims_differ = ($active_dims > 0 && $selected_dims > 0 && $active_dims !== $selected_dims); $kb_url = admin_url('admin.php?page=mxchat-prompts'); $actions_url = admin_url('admin.php?page=mxchat-actions'); ?>

' . esc_html($active_label) . '', '' . esc_html($selected_label) . '' ); ?>

· ·

'', 'xai_api_key' => '', 'claude_api_key' => '', 'deepseek_api_key' => '', 'voyage_api_key' => '', 'gemini_api_key' => '', 'enable_streaming_toggle' => 'off', 'enable_web_search' => 'off', 'embedding_model' => 'text-embedding-ada-002', '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: # Response Style - CRITICALLY IMPORTANT - MAXIMUM LENGTH: 1-3 short sentences per response - Ultra-concise: Get straight to the answer with no filler - No introductions like "Sure!" or "I\'d be happy to help" - No phrases like "based on my knowledge" or "according to information" - No explanatory text before giving the answer - No summaries or repetition - Hyperlink all URLs - Respond in user\'s language - Minor chit chat or conversation is okay, but try to keep it focused on [insert topic] # Knowledge Base Requirements - PREVENT HALLUCINATIONS - ONLY answer questions using information explicitly provided in OFFICIAL KNOWLEDGE DATABASE CONTENT sections marked with ===== delimiters - If required information is NOT in the knowledge database: "I don\'t have enough information in my knowledge base to answer that question accurately." - NEVER invent or hallucinate URLs, links, product specs, procedures, dates, statistics, names, contacts, or company information - When knowledge base information is unclear or contradictory, acknowledge the limitation rather than guessing - Better to admit insufficient information than provide inaccurate answers', 'model' => 'gpt-5.6-sol', 'rate_limit_logged_out' => '100', 'role_rate_limits' => array(), 'rate_limit_message' => esc_html__('Rate limit exceeded. Please try again later.', 'mxchat'), 'enable_email_block' => '', 'email_blocker_header_content' => __("

Welcome to Our Chat!

\n

Let's get started. Enter your email to begin chatting with us.

", 'mxchat'), 'email_blocker_button_text' => esc_html__('Start Chat', 'mxchat'), 'enable_name_field' => 'off', // NEW 'name_field_placeholder' => esc_html__('Enter your name', 'mxchat'), // NEW 'enable_consent_checkbox' => 'off', // b062c4 — default OFF: changes an existing form on every install 'consent_checkbox_label' => __('I agree to the Privacy Policy.', 'mxchat'), 'consent_checkbox_required' => 'off', // Lead capture options (c0cfaf). Both default to today's behaviour: // the form is hidden from logged-in visitors, and email is required. 'lead_capture_logged_in_toggle' => 'off', 'lead_capture_require_email_toggle' => 'on', 'top_bar_title' => esc_html__('MxChat', 'mxchat'), 'intro_message' => __('Hello! How can I assist you today?', 'mxchat'), 'ai_agent_text' => esc_html__('AI Agent', 'mxchat'), 'input_copy' => esc_html__('How can I assist?', 'mxchat'), 'append_to_body' => 'off', 'post_type_visibility_mode' => 'all', // 'all', 'include', 'exclude' 'post_type_visibility_list' => array(), // Array of post type slugs 'contextual_awareness_toggle' => 'off', 'citation_links_toggle' => 'on', 'satisfaction_rating_enabled' => 'off', 'satisfaction_rating_idle_seconds' => 60, 'satisfaction_rating_question' => '', 'satisfaction_rating_thanks' => '', 'satisfaction_rating_placeholder' => '', 'satisfaction_rating_saved' => '', 'close_button_color' => '#fff', 'chatbot_bg_color' => '#fff', 'user_message_bg_color' => '#fff', 'user_message_font_color' => '#212121', 'bot_message_bg_color' => '#212121', 'bot_message_font_color' => '#fff', 'top_bar_bg_color' => '#212121', 'send_button_font_color' => '#212121', 'chat_input_font_color' => '#212121', 'chatbot_background_color' => '#212121', 'icon_color' => '#fff', 'enable_woocommerce_integration' => '0', 'link_target_toggle' => 'off', 'pre_chat_message' => esc_html__('Hey there! Ask me anything!', 'mxchat'), // New fields for Loops Integration 'loops_api_key' => '', 'loops_mailing_list' => '', 'triggered_phrase_response' => __('Would you like to join our mailing list? Please provide your email below.', 'mxchat'), 'email_capture_response' => __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat'), 'popular_question_1' => '', 'popular_question_2' => '', 'popular_question_3' => '', 'pdf_intent_trigger_text' => __("Please provide the URL to the PDF you'd like to discuss.", 'mxchat'), 'pdf_intent_success_text' => __("I've processed the PDF. What questions do you have about it?", 'mxchat'), 'pdf_intent_error_text' => __("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat'), 'pdf_max_pages' => 69, 'show_pdf_upload_button' => 'on', 'show_word_upload_button' => 'on', // Live Agent Integration (Slack) 'live_agent_webhook_url' => '', 'live_agent_secret_key' => '', 'live_agent_bot_token' => '', 'live_agent_message_bg_color' => '#ffffff', 'live_agent_message_font_color' => '#333333', // Telegram Integration 'telegram_status' => 'off', 'telegram_bot_token' => '', 'telegram_group_id' => '', 'telegram_webhook_secret' => '', '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'), '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'), // Webhook handoff destination (plan d88e22, outbound-only) 'webhook_handoff_status' => 'off', 'webhook_handoff_url' => '', 'webhook_handoff_secret' => '', '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'), 'webhook_handoff_away_message' => __('Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.', 'mxchat'), 'chat_toolbar_toggle' => 'off', 'mode_indicator_bg_color' => '#767676', 'mode_indicator_font_color' => '#ffffff', 'toolbar_icon_color' => '#212121', // Optimization settings 'script_loading_strategy' => 'default', // Debug settings 'debug_mode' => 'off', ); // Merge existing options with defaults $existing_options = get_option('mxchat_options', array()); $merged_options = wp_parse_args($existing_options, $default_options); // Update the options if they have changed if ($existing_options !== $merged_options) { update_option('mxchat_options', $merged_options); } // Add default limits for each role $roles = wp_roles()->get_names(); foreach ($roles as $role_id => $role_name) { $default_options['role_rate_limits'][$role_id] = '100'; } return $default_options; // Update the $this->options property $this->options = $merged_options; } public function mxchat_add_plugin_page() { // Onboarding lifecycle helpers (admin_init redirect + ajax handlers live in the file). require_once plugin_dir_path(__FILE__) . 'admin-onboarding-page.php'; // Main menu page — `mxchat-max` remains the parent slug for every MxChat submenu // (Settings, Knowledge, Transcripts, …). Hitting `?page=mxchat-max` directly now // dispatches to the Onboarding page (or Settings if the user has dismissed onboarding). add_menu_page( esc_html__('MxChat', 'mxchat'), esc_html__('MxChat', 'mxchat'), 'manage_options', 'mxchat-max', array($this, 'mxchat_create_dashboard_page'), 'dashicons-testimonial', 6 ); // Onboarding submenu — first child under MxChat (plan-d14e89). // First registration uses menu_slug === parent slug 'mxchat-max' → // WP-canonical override of the auto-duplicate "MxChat" entry. Result: the // first child shows as "Onboarding" instead of a redundant pair. add_submenu_page( 'mxchat-max', esc_html__('MxChat Onboarding', 'mxchat'), esc_html__('Onboarding', 'mxchat'), 'manage_options', 'mxchat-max', array($this, 'mxchat_create_dashboard_page') ); // Hidden route for `?page=mxchat-onboarding` (Settings "Show again" link // + legacy redirects still target this slug). Parent === null keeps it // out of the menu while remaining accessible by URL. add_submenu_page( null, esc_html__('MxChat Onboarding', 'mxchat'), esc_html__('Onboarding', 'mxchat'), 'manage_options', 'mxchat-onboarding', array($this, 'mxchat_create_dashboard_page') ); // Settings submenu — same callback as before, just at a new slug. add_submenu_page( 'mxchat-max', esc_html__('MxChat Settings', 'mxchat'), esc_html__('Settings', 'mxchat'), 'manage_options', 'mxchat-settings', array($this, 'mxchat_create_admin_page') ); // Submenu page for Knowledge add_submenu_page( 'mxchat-max', esc_html__('Prompts', 'mxchat'), esc_html__('Knowledge', 'mxchat'), 'manage_options', 'mxchat-prompts', array($this, 'mxchat_create_prompts_page') ); // Transcripts opens for administrators exactly as before, and read-only for // a role holding the transcripts capability (plan 93999a). Every other // MxChat submenu keeps manage_options, so that role sees only this item. $this->transcripts_page_hook = (string) add_submenu_page( 'mxchat-max', esc_html__('Chat Transcripts', 'mxchat'), esc_html__('Transcripts', 'mxchat'), current_user_can('manage_options') ? 'manage_options' : self::mxchat_transcripts_capability(), 'mxchat-transcripts', array($this, 'mxchat_create_transcripts_page') ); add_submenu_page( 'mxchat-max', esc_html__('MxChat Actions', 'mxchat'), esc_html__('Actions', 'mxchat'), 'manage_options', 'mxchat-actions', array($this, 'mxchat_actions_page_html') ); // Content Generator page add_submenu_page( 'mxchat-max', esc_html__('Content', 'mxchat'), esc_html__('Content', 'mxchat'), 'manage_options', 'mxchat-content', array($this, 'mxchat_create_content_page') ); } /** * Register the Pro & Extensions submenu on a later admin_menu priority so it * always renders as the bottom-most item in the MxChat sidebar — below * configuration pages like API Access (priority 20). */ public function mxchat_add_pro_extensions_page() { add_submenu_page( 'mxchat-max', esc_html__('Pro & Extensions', 'mxchat'), esc_html__('Pro & Extensions', 'mxchat'), 'manage_options', 'mxchat-activation', array($this, 'mxchat_create_activation_page') ); } public function mxchat_create_addons_page() { require_once plugin_dir_path(__FILE__) . 'class-mxchat-addons.php'; $addons_page = new MxChat_Addons(); $addons_page->render_page(); } /** * Render the Content Generator admin page */ public function mxchat_create_content_page() { require_once plugin_dir_path(__FILE__) . 'admin-content-page.php'; // The Editor Assistant toggle on the Settings tab renders through // mxchat_render_field_wrapper(), which lives in admin-settings-page.php // (plan-f7df40 relocation) — load it the same way the settings page does. require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php'; mxchat_render_content_page($this); } public function register_pinecone_settings() { register_setting( 'mxchat_pinecone_addon_options', 'mxchat_pinecone_addon_options', array( 'type' => 'array', 'sanitize_callback' => array($this, 'sanitize_pinecone_settings'), 'default' => array( 'mxchat_use_pinecone' => '0', 'mxchat_pinecone_api_key' => '', 'mxchat_pinecone_host' => '', 'mxchat_pinecone_index' => '', 'mxchat_pinecone_environment' => '', 'mxchat_pinecone_top_k' => '50' ) ) ); } public function sanitize_pinecone_settings($input) { $sanitized = array(); // The form posts value="1" for a ticked box and no key for an unticked one; a caller // that passes '0' explicitly must not switch Pinecone ON (plan 95b2da). $sanitized['mxchat_use_pinecone'] = (isset($input['mxchat_use_pinecone']) && (string) $input['mxchat_use_pinecone'] === '1') ? '1' : '0'; $sanitized['mxchat_pinecone_api_key'] = sanitize_text_field($input['mxchat_pinecone_api_key'] ?? ''); $sanitized['mxchat_pinecone_host'] = sanitize_text_field($input['mxchat_pinecone_host'] ?? ''); $sanitized['mxchat_pinecone_index'] = sanitize_text_field($input['mxchat_pinecone_index'] ?? ''); $sanitized['mxchat_pinecone_environment'] = sanitize_text_field($input['mxchat_pinecone_environment'] ?? ''); // d0cae1: this function REBUILDS the array, so the topK key must be // carried here or any form save strips it. Clamp mirrors the read site. $top_k = absint($input['mxchat_pinecone_top_k'] ?? 50); $sanitized['mxchat_pinecone_top_k'] = (string) (($top_k >= 1 && $top_k <= 1000) ? $top_k : 50); // Remove https:// from host if present $sanitized['mxchat_pinecone_host'] = str_replace(['https://', 'http://'], '', $sanitized['mxchat_pinecone_host']); // Index type (plan 362c31). The document index is opt-in, and its host // must have passed Check index (or come from Create index for me) before // the choice sticks — a classic host routed through the documents API // answers 400 on every query. The verified host is carried from the // stored option; only the AJAX handlers set it, never the form. $existing = get_option('mxchat_pinecone_addon_options', array()); $existing = is_array($existing) ? $existing : array(); $verified_host = (string) ($existing['mxchat_pinecone_docs_verified_host'] ?? ''); $index_type = (($input['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector'; $host_for_check = strtolower(trim($sanitized['mxchat_pinecone_host'], '/')); if ($index_type === 'document' && ($host_for_check === '' || $host_for_check !== strtolower($verified_host))) { $index_type = 'vector'; if (function_exists('add_settings_error')) { 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')); } } // 3e83e4: never point the live chatbot at a document index whose copy is // still running — the save keeps the previous type unless the owner // ticked Switch anyway. Only a vector → document switch is gated. $existing_type = (($existing['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector'; if ($index_type === 'document' && $existing_type !== 'document' && class_exists('MxChat_Pinecone_Documents') && empty($input['mxchat_pinecone_docs_switch_anyway'])) { $running = MxChat_Pinecone_Documents::migration_blocks_switch($host_for_check); if ($running !== null) { $index_type = 'vector'; if (function_exists('add_settings_error')) { add_settings_error('mxchat_pinecone_addon_options', 'mxchat_pinecone_docs_copy_running', MxChat_Pinecone_Documents::switch_blocked_message($running)); } } } $sanitized['mxchat_pinecone_index_type'] = $index_type; $sanitized['mxchat_pinecone_docs_verified_host'] = $verified_host; $vector_host = str_replace(['https://', 'http://'], '', sanitize_text_field($input['mxchat_pinecone_vector_host'] ?? ($existing['mxchat_pinecone_vector_host'] ?? ''))); $sanitized['mxchat_pinecone_vector_host'] = trim($vector_host, '/'); $sanitized['mxchat_pinecone_vector_index'] = sanitize_text_field($input['mxchat_pinecone_vector_index'] ?? ($existing['mxchat_pinecone_vector_index'] ?? '')); $docs_cloud = sanitize_key($input['mxchat_pinecone_docs_cloud'] ?? ($existing['mxchat_pinecone_docs_cloud'] ?? 'aws')); $sanitized['mxchat_pinecone_docs_cloud'] = in_array($docs_cloud, array('aws', 'gcp', 'azure'), true) ? $docs_cloud : 'aws'; $docs_region = sanitize_text_field($input['mxchat_pinecone_docs_region'] ?? ($existing['mxchat_pinecone_docs_region'] ?? 'us-east-1')); $sanitized['mxchat_pinecone_docs_region'] = $docs_region !== '' ? $docs_region : 'us-east-1'; $docs_language = sanitize_key($input['mxchat_pinecone_docs_language'] ?? ($existing['mxchat_pinecone_docs_language'] ?? 'en')); $sanitized['mxchat_pinecone_docs_language'] = (class_exists('MxChat_Pinecone_Documents') && array_key_exists($docs_language, MxChat_Pinecone_Documents::languages())) ? $docs_language : 'en'; // The namespace has no form field; carry the stored value so a form save cannot drop it. if (isset($existing['mxchat_pinecone_namespace'])) { $sanitized['mxchat_pinecone_namespace'] = sanitize_text_field($existing['mxchat_pinecone_namespace']); } return $sanitized; } public function register_openai_vectorstore_settings() { register_setting( 'mxchat_openai_vectorstore_options', 'mxchat_openai_vectorstore_options', array( 'type' => 'array', 'sanitize_callback' => array($this, 'sanitize_openai_vectorstore_settings'), 'default' => array( 'mxchat_use_openai_vectorstore' => '0', 'mxchat_vectorstore_ids' => '', 'mxchat_vectorstore_max_results' => 5, 'mxchat_vectorstore_sync_enabled' => '0', 'mxchat_vectorstore_sync_store_id' => '' ) ) ); } public function sanitize_openai_vectorstore_settings($input) { $sanitized = array(); $sanitized['mxchat_use_openai_vectorstore'] = isset($input['mxchat_use_openai_vectorstore']) ? '1' : '0'; $sanitized['mxchat_vectorstore_ids'] = sanitize_text_field($input['mxchat_vectorstore_ids'] ?? ''); $sanitized['mxchat_vectorstore_max_results'] = absint($input['mxchat_vectorstore_max_results'] ?? 5); // Knowledge-base sync (write path, plan 15b5c6). Store ID may be blank — // the sync falls back to the first retrieval ID above. $sanitized['mxchat_vectorstore_sync_enabled'] = isset($input['mxchat_vectorstore_sync_enabled']) ? '1' : '0'; $sanitized['mxchat_vectorstore_sync_store_id'] = sanitize_text_field($input['mxchat_vectorstore_sync_store_id'] ?? ''); // Ensure max results is within reasonable range if ($sanitized['mxchat_vectorstore_max_results'] < 1) { $sanitized['mxchat_vectorstore_max_results'] = 1; } if ($sanitized['mxchat_vectorstore_max_results'] > 20) { $sanitized['mxchat_vectorstore_max_results'] = 20; } return $sanitized; } /** * AJAX handler to test OpenAI Vector Store connection */ public function mxchat_test_vectorstore_connection() { check_ajax_referer('mxchat_admin_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Permission denied.', 'mxchat'))); return; } $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array()); $vectorstore_ids = $vectorstore_options['mxchat_vectorstore_ids'] ?? ''; $mxchat_options = get_option('mxchat_options', array()); $api_key = $mxchat_options['api_key'] ?? ''; if (empty($api_key)) { wp_send_json_error(array('message' => __('OpenAI API key is not configured.', 'mxchat'))); return; } if (empty($vectorstore_ids)) { wp_send_json_error(array('message' => __('No Vector Store ID configured.', 'mxchat'))); return; } // Get the first Vector Store ID for testing $ids_array = array_map('trim', explode(',', $vectorstore_ids)); $test_id = $ids_array[0]; // Test by retrieving the Vector Store info $response = wp_remote_get( 'https://api.openai.com/v1/vector_stores/' . $test_id, array( 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', 'OpenAI-Beta' => 'assistants=v2' ), 'timeout' => 30 ) ); if (is_wp_error($response)) { wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $response->get_error_message())); return; } $status_code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if ($status_code === 200 && isset($body['id'])) { $file_count = $body['file_counts']['completed'] ?? 0; $name = $body['name'] ?? $test_id; wp_send_json_success(array( 'message' => sprintf( __('Connected successfully! Vector Store: %s (%d files)', 'mxchat'), esc_html($name), $file_count ) )); } elseif ($status_code === 404) { wp_send_json_error(array('message' => __('Vector Store not found. Please check the ID.', 'mxchat'))); } elseif ($status_code === 401) { wp_send_json_error(array('message' => __('Invalid API key.', 'mxchat'))); } else { $error_message = $body['error']['message'] ?? __('Unknown error occurred.', 'mxchat'); wp_send_json_error(array('message' => $error_message)); } } /** * AJAX handler to test Slack connection and validate scopes */ public function mxchat_test_slack_connection() { check_ajax_referer('mxchat_admin_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Permission denied.', 'mxchat'))); return; } $bot_token = $this->options['live_agent_bot_token'] ?? ''; if (empty($bot_token)) { wp_send_json_error(array('message' => __('Slack Bot Token is not configured. Please enter your token and save settings first.', 'mxchat'))); return; } // Test 1: Validate bot token with auth.test $auth_response = wp_remote_post('https://slack.com/api/auth.test', array( 'headers' => array( 'Authorization' => 'Bearer ' . $bot_token, 'Content-Type' => 'application/json' ), 'timeout' => 15 )); if (is_wp_error($auth_response)) { wp_send_json_error(array('message' => __('Connection failed: ', 'mxchat') . $auth_response->get_error_message())); return; } $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true); if (!isset($auth_body['ok']) || !$auth_body['ok']) { $error = $auth_body['error'] ?? 'unknown_error'; $error_messages = array( 'invalid_auth' => __('Invalid bot token. Please check your token starts with xoxb-', 'mxchat'), 'not_authed' => __('No authentication token provided.', 'mxchat'), 'account_inactive' => __('The Slack workspace has been deactivated.', 'mxchat'), 'token_revoked' => __('The bot token has been revoked. Please generate a new one.', 'mxchat'), ); $message = $error_messages[$error] ?? sprintf(__('Authentication failed: %s', 'mxchat'), $error); wp_send_json_error(array('message' => $message)); return; } $team_name = $auth_body['team'] ?? 'Unknown Workspace'; $bot_name = $auth_body['user'] ?? 'Unknown Bot'; // Test 2: Check if we can list channels (tests channels:read scope) $channels_response = wp_remote_post('https://slack.com/api/conversations.list', array( 'headers' => array( 'Authorization' => 'Bearer ' . $bot_token, 'Content-Type' => 'application/json' ), 'body' => json_encode(array('limit' => 1)), 'timeout' => 15 )); $channels_body = json_decode(wp_remote_retrieve_body($channels_response), true); $can_read_channels = isset($channels_body['ok']) && $channels_body['ok']; // Test 3: Check if we can create channels (tests channels:manage scope) // We'll just check the error message without actually creating $create_response = wp_remote_post('https://slack.com/api/conversations.create', array( 'headers' => array( 'Authorization' => 'Bearer ' . $bot_token, 'Content-Type' => 'application/json' ), 'body' => json_encode(array('name' => 'mxchat-test-' . time(), 'is_private' => false)), 'timeout' => 15 )); $create_body = json_decode(wp_remote_retrieve_body($create_response), true); // If channel was created, delete it immediately if (isset($create_body['ok']) && $create_body['ok'] && isset($create_body['channel']['id'])) { wp_remote_post('https://slack.com/api/conversations.archive', array( 'headers' => array( 'Authorization' => 'Bearer ' . $bot_token, 'Content-Type' => 'application/json' ), 'body' => json_encode(array('channel' => $create_body['channel']['id'])), 'timeout' => 15 )); $can_create_channels = true; } else { $create_error = $create_body['error'] ?? ''; // name_taken means we have permission but channel exists $can_create_channels = ($create_error === 'name_taken' || (isset($create_body['ok']) && $create_body['ok'])); // Check for missing scope errors if ($create_error === 'missing_scope') { $can_create_channels = false; } } // Build result message $results = array(); $results[] = sprintf(__('Workspace: %s', 'mxchat'), esc_html($team_name)); $results[] = sprintf(__('Bot: %s', 'mxchat'), esc_html($bot_name)); $results[] = ''; $results[] = ($can_read_channels ? '✓' : '✗') . ' ' . __('channels:read - List channels', 'mxchat'); $results[] = ($can_create_channels ? '✓' : '✗') . ' ' . __('channels:manage - Create channels', 'mxchat'); $missing_scopes = array(); if (!$can_read_channels) $missing_scopes[] = 'channels:read'; if (!$can_create_channels) $missing_scopes[] = 'channels:manage'; if (!empty($missing_scopes)) { wp_send_json_error(array( 'message' => implode("\n", $results), 'missing_scopes' => $missing_scopes, 'partial' => true )); } else { wp_send_json_success(array( 'message' => implode("\n", $results) )); } } public function mxchat_display_admin_notice() { // Success notice if ($message = get_transient('mxchat_admin_notice_success')) { ?>

🔧 Live Agent Integration Updated!

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 Settings → Integrations → Toolbar and reviewing the new configuration options.

mxchat_create_admin_page(); } /** * Hide the Onboarding submenu when the user has dismissed it (either * manually or via auto-graduation). The page itself remains routable so * the Settings "Show MxChat Onboarding again" link can navigate back to it. */ public function mxchat_apply_onboarding_visibility() { if (!function_exists('mxchat_onboarding_is_dismissed')) { return; } if (mxchat_onboarding_is_dismissed()) { // The first MxChat child is the same-slug-as-parent registration // (slug 'mxchat-max', labelled "Onboarding") added in plan-d14e89. // Remove it so the menu opens straight to Settings after dismiss. remove_submenu_page('mxchat-max', 'mxchat-max'); } } public function mxchat_create_admin_page() { $this->add_live_agent_nonce(); $this->add_theme_migration_nonce(); // Include and render the new sidebar-based settings page require_once plugin_dir_path(__FILE__) . 'admin-settings-page.php'; mxchat_render_settings_page($this); } public function dismiss_live_agent_notice() { // Add debugging //error_log('dismiss_live_agent_notice called'); //error_log('POST data: ' . print_r($_POST, true)); // Verify nonce if (!wp_verify_nonce($_POST['nonce'], 'dismiss_live_agent_notice')) { //error_log('Nonce verification failed'); wp_die('Security check failed'); } // Remove the notice flag $deleted = delete_option('mxchat_show_live_agent_disabled_notice'); //error_log('Option deleted: ' . ($deleted ? 'yes' : 'no')); wp_send_json_success(); } public function add_live_agent_nonce() { if (get_option('mxchat_show_live_agent_disabled_notice', false)) { // Make sure your admin script is enqueued and localize the data wp_localize_script('mxchat-admin-js', 'mxchatLiveAgent', array( 'nonce' => wp_create_nonce('dismiss_live_agent_notice'), 'ajaxurl' => admin_url('admin-ajax.php') )); } } /** * Show theme migration notice for Pro users with AI-generated themes * Only shown once - dismissible and stored in options */ public function show_theme_migration_banner() { // Only show if Pro is activated if (!$this->is_activated) { return; } // Check if notice should be shown $show_notice = get_option('mxchat_show_theme_migration_notice', false); if ($show_notice) { ?>

🎨 AI Theme Migration Required

If you're using an AI-generated chatbot theme, you'll need to migrate it to match the new CSS structure. Go to Theme Settings, select your theme from the sidebar, and click the Migrate button.

is_activated) { wp_localize_script('mxchat-admin-js', 'mxchatThemeMigration', array( 'nonce' => wp_create_nonce('dismiss_theme_migration_notice'), 'ajaxurl' => admin_url('admin-ajax.php') )); } } public function mxchat_create_transcripts_page() { if (!self::mxchat_can_view_transcripts()) { wp_die(esc_html__('Sorry, you are not allowed to access this page.', 'mxchat'), '', array('response' => 403)); } global $wpdb; $table_name = $wpdb->prefix . 'mxchat_chat_transcripts'; // Get basic stats $total_chats = $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM $table_name") ?: 0; $total_messages = $wpdb->get_var("SELECT COUNT(*) FROM $table_name") ?: 0; // Count unique users with detailed breakdown. Bot replies are saved with role 'bot' (plan 0c480a: the old // role != 'assistant' filter excluded nothing); 'assistant' stays in the list for any older rows. $total_users = $wpdb->get_var(" SELECT COUNT(DISTINCT CASE WHEN user_email != '' AND user_email IS NOT NULL THEN user_email WHEN user_id != 0 THEN CONCAT('user_', user_id) WHEN user_identifier NOT LIKE 'Tech-Savvy User' AND user_identifier NOT LIKE 'Detail-Oriented User' AND user_identifier NOT LIKE 'Language Learner' AND user_identifier NOT LIKE 'Casual Browser' AND user_identifier NOT LIKE 'Policy Enforcer' AND user_identifier NOT LIKE 'Researcher' AND user_identifier NOT LIKE 'Loyalty Member' AND user_identifier NOT LIKE 'Gift Buyer' AND user_identifier NOT LIKE 'Parent or Caregiver' THEN user_identifier ELSE session_id END ) FROM $table_name WHERE role NOT IN ('assistant', 'bot') "); // Get user type breakdown $registered_users = $wpdb->get_var(" SELECT COUNT(DISTINCT user_email) FROM $table_name WHERE user_email != '' AND user_email IS NOT NULL "); $guest_users = $wpdb->get_var(" SELECT COUNT(DISTINCT user_identifier) FROM $table_name WHERE (user_email = '' OR user_email IS NULL) AND role NOT IN ('assistant', 'bot') AND user_identifier NOT LIKE 'Tech-Savvy User' AND user_identifier NOT LIKE 'Detail-Oriented User' AND user_identifier NOT LIKE 'Language Learner' AND user_identifier NOT LIKE 'Casual Browser' AND user_identifier NOT LIKE 'Policy Enforcer' AND user_identifier NOT LIKE 'Researcher' AND user_identifier NOT LIKE 'Loyalty Member' AND user_identifier NOT LIKE 'Gift Buyer' AND user_identifier NOT LIKE 'Parent or Caregiver' "); // Get agent test messages count $agent_tests = $wpdb->get_var(" SELECT COUNT(DISTINCT session_id) FROM $table_name WHERE user_identifier IN ( 'Tech-Savvy User', 'Detail-Oriented User', 'Language Learner', 'Casual Browser', 'Policy Enforcer', 'Researcher', 'Loyalty Member', 'Gift Buyer', 'Parent or Caregiver' ) "); // Get activity metrics $today_chats = $wpdb->get_var(" SELECT COUNT(DISTINCT session_id) FROM $table_name WHERE DATE(timestamp) = CURDATE() "); $week_chats = $wpdb->get_var(" SELECT COUNT(DISTINCT session_id) FROM $table_name WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY) "); $month_chats = $wpdb->get_var(" SELECT COUNT(DISTINCT session_id) FROM $table_name WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 30 DAY) "); // Get daily chat data for last 7 days $daily_stats = $wpdb->get_results(" SELECT DATE(timestamp) as date, COUNT(DISTINCT session_id) as chats, COUNT(*) as messages FROM $table_name WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY DATE(timestamp) ORDER BY date ASC "); // Get average messages per chat $avg_messages = $wpdb->get_var(" SELECT AVG(message_count) FROM ( SELECT session_id, COUNT(*) as message_count FROM $table_name GROUP BY session_id ) as chat_counts "); $avg_messages = $avg_messages ? round($avg_messages, 1) : 0; // Get busiest hour $busiest_hour = $wpdb->get_row(" SELECT HOUR(timestamp) as hour, COUNT(DISTINCT session_id) as chat_count FROM $table_name GROUP BY HOUR(timestamp) ORDER BY chat_count DESC LIMIT 1 "); // Prepare chart data $chart_labels = array(); $chart_chats = array(); $chart_messages = array(); // Fill last 7 days with data for ($i = 6; $i >= 0; $i--) { $date = date('Y-m-d', strtotime("-$i days")); $day_name = date('D', strtotime("-$i days")); $chart_labels[] = $day_name; $found = false; foreach ($daily_stats as $stat) { if ($stat->date === $date) { $chart_chats[] = (int)$stat->chats; $chart_messages[] = (int)$stat->messages; $found = true; break; } } if (!$found) { $chart_chats[] = 0; $chart_messages[] = 0; } } // Satisfaction rating rollup — last 30 days, grouped by bot (plan-a5b006). $satisfaction_stats = $this->get_satisfaction_rating_stats(30); // Prepare page data for the template $page_data = array( 'total_chats' => $total_chats, 'total_messages' => $total_messages, 'total_users' => $total_users, 'registered_users' => $registered_users, 'guest_users' => $guest_users, 'agent_tests' => $agent_tests, 'today_chats' => $today_chats, 'week_chats' => $week_chats, 'month_chats' => $month_chats, 'avg_messages' => $avg_messages, 'busiest_hour' => $busiest_hour, 'chart_labels' => $chart_labels, 'chart_chats' => $chart_chats, 'chart_messages' => $chart_messages, 'satisfaction_stats' => $satisfaction_stats, ); // Include and render the new template require_once plugin_dir_path(__FILE__) . 'admin-transcripts-page.php'; mxchat_render_transcripts_page($this, $page_data); } /** * Per-bot satisfaction rating rollup over the last $days days. Used by the * Satisfaction card on the Transcripts dashboard (plan-a5b006). * * @param int $days Window in days. * @return array Each entry: ['bot_id', 'total', 'positive', 'negative', 'positive_pct', 'negative_pct']. */ public function get_satisfaction_rating_stats($days = 30) { global $wpdb; $table = $wpdb->prefix . 'mxchat_session_ratings'; if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) !== $table) { return array(); } $days = max(1, (int) $days); $rows = $wpdb->get_results($wpdb->prepare( "SELECT bot_id, COUNT(*) AS total, SUM(CASE WHEN rating_value = 1 THEN 1 ELSE 0 END) AS positive, SUM(CASE WHEN rating_value = -1 THEN 1 ELSE 0 END) AS negative FROM {$table} WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY) GROUP BY bot_id ORDER BY total DESC", $days )); $out = array(); foreach ((array) $rows as $row) { $total = (int) $row->total; $positive = (int) $row->positive; $negative = (int) $row->negative; $out[] = array( 'bot_id' => $row->bot_id ?: 'default', 'total' => $total, 'positive' => $positive, 'negative' => $negative, 'positive_pct' => $total > 0 ? (int) round(($positive / $total) * 100) : 0, 'negative_pct' => $total > 0 ? (int) round(($negative / $total) * 100) : 0, ); } return $out; } /** * Get chart data for transcripts page * Used by both page render and script localization * * @return array Chart data with labels, chats, and messages arrays */ private function get_transcripts_chart_data() { global $wpdb; $table_name = $wpdb->prefix . 'mxchat_chat_transcripts'; // Get daily chat data for last 7 days - same query as mxchat_transcripts_page() $daily_stats = $wpdb->get_results(" SELECT DATE(timestamp) as date, COUNT(DISTINCT session_id) as chats, COUNT(*) as messages FROM $table_name WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY DATE(timestamp) ORDER BY date ASC "); // Prepare chart data $chart_labels = array(); $chart_chats = array(); $chart_messages = array(); // Fill last 7 days with data - same logic as mxchat_transcripts_page() for ($i = 6; $i >= 0; $i--) { $date = date('Y-m-d', strtotime("-$i days")); $day_name = date('D', strtotime("-$i days")); $chart_labels[] = $day_name; $found = false; if ($daily_stats) { foreach ($daily_stats as $stat) { if ($stat->date === $date) { $chart_chats[] = (int)$stat->chats; $chart_messages[] = (int)$stat->messages; $found = true; break; } } } if (!$found) { $chart_chats[] = 0; $chart_messages[] = 0; } } return array( 'labels' => $chart_labels, 'chats' => $chart_chats, 'messages' => $chart_messages ); } public function mxchat_transcripts_notification_section_callback() { echo '

' . esc_html__('Configure email notifications for new chat transcripts. You will receive an email notification when a new chat session begins.', 'mxchat') . '

'; } public function mxchat_enable_notifications_callback() { $options = get_option('mxchat_transcripts_options', array()); $enabled = isset($options['mxchat_enable_notifications']) ? $options['mxchat_enable_notifications'] : 0; ?>

0 it overrides the bucket dropdown above * and deletes transcripts older than the given number of days. Set to 0 to fall * back to the dropdown (or "Never" if the dropdown is also Never). * * Devs can override the final day count via the `mxchat_transcript_retention_days` * filter — runs in `cleanup_old_transcripts()` after this option is read. * * (plan-mxchat-20260509-9b80b1) */ public function mxchat_retention_days_callback() { $options = get_option('mxchat_transcripts_options', array()); $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0; ?>


apply_filters( 'mxchat_transcript_retention_days', $days )






0) as a // reason to keep the daily cron registered. $interval_changed = ($old_interval !== $sanitized['mxchat_auto_delete_transcripts']); $retention_changed = ($old_retention !== $sanitized['mxchat_retention_days']); if ($interval_changed || $retention_changed) { $any_active = ($sanitized['mxchat_auto_delete_transcripts'] !== 'never') || ($sanitized['mxchat_retention_days'] > 0); $this->schedule_transcript_cleanup($any_active ? 'active' : 'never'); } // Sanitize auto-email transcript settings $sanitized['mxchat_auto_email_transcript_enabled'] = isset($input['mxchat_auto_email_transcript_enabled']) ? 1 : 0; $valid_delays = array('15', '30', '60'); if (isset($input['mxchat_auto_email_transcript_delay'])) { $sanitized['mxchat_auto_email_transcript_delay'] = in_array($input['mxchat_auto_email_transcript_delay'], $valid_delays) ? $input['mxchat_auto_email_transcript_delay'] : '30'; } else { $sanitized['mxchat_auto_email_transcript_delay'] = '30'; } // Sanitize require contact info setting $sanitized['mxchat_auto_email_transcript_require_contact'] = isset($input['mxchat_auto_email_transcript_require_contact']) ? 1 : 0; return $sanitized; } /** * Schedule or unschedule the transcript cleanup cron job */ public function schedule_transcript_cleanup($interval) { // Clear any existing scheduled event $timestamp = wp_next_scheduled('mxchat_cleanup_old_transcripts'); if ($timestamp) { wp_unschedule_event($timestamp, 'mxchat_cleanup_old_transcripts'); } // Schedule new event whenever retention is active. "active" is the canonical // value passed by sanitize_transcripts_options when either the dropdown != never // OR the custom retention-days > 0; "never" turns the cron off. Any other value // (the legacy "1week" / "2weeks" / "1month" strings) is also treated as active. if ($interval !== 'never') { // Schedule to run daily at 3 AM $next_run = strtotime('tomorrow 3:00 AM'); wp_schedule_event($next_run, 'daily', 'mxchat_cleanup_old_transcripts'); } } /** * Self-heal guard: keep the cleanup cron in sync with the retention setting. * * Runs on admin_init. Cost when nothing is wrong: one get_option() (cached) and * one wp_next_scheduled() (reads the cached cron option) — no writes. It only * schedules when retention is active but the event is missing, and only * unschedules when retention is off but the event survived. Both directions * reuse schedule_transcript_cleanup() so there is exactly one scheduling path. * Legacy dropdown values (1week/2weeks/1month) count as active, matching the * helper's own semantics. Deliberately not gated on DISABLE_WP_CRON: scheduling * writes the cron option regardless of how cron is executed, so a server-side * cron runner still picks the event up. */ public function ensure_transcript_cleanup_scheduled() { $options = get_option('mxchat_transcripts_options', array()); $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never'; $days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0; $active = ($interval !== 'never') || ($days > 0); $scheduled = (bool) wp_next_scheduled('mxchat_cleanup_old_transcripts'); if ($active && !$scheduled) { $this->schedule_transcript_cleanup('active'); } elseif (!$active && $scheduled) { $this->schedule_transcript_cleanup('never'); } } /** * Delete old transcripts based on the configured interval */ public function cleanup_old_transcripts() { $options = get_option('mxchat_transcripts_options', array()); $interval = isset($options['mxchat_auto_delete_transcripts']) ? $options['mxchat_auto_delete_transcripts'] : 'never'; $custom_days = isset($options['mxchat_retention_days']) ? (int) $options['mxchat_retention_days'] : 0; // Custom retention-days (plan-9b80b1) takes precedence over the bucket dropdown. $days = 0; if ($custom_days > 0) { $days = $custom_days; } else { switch ($interval) { case '1week': $days = 7; break; case '2weeks': $days = 14; break; case '1month': $days = 30; break; case 'never': default: $days = 0; } } // Devs can override the final day count programmatically. $days = (int) apply_filters('mxchat_transcript_retention_days', $days); if ($days <= 0) { return; // Retention disabled — bail. } global $wpdb; $transcripts_table = $wpdb->prefix . 'mxchat_chat_transcripts'; $translations_table = $wpdb->prefix . 'mxchat_transcript_translations'; $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks'; // Defensive: if the main table doesn't exist (fresh-ish install), bail. if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $transcripts_table)) !== $transcripts_table) { return; } $cutoff_date = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); // Cap at 5000 session_ids per run so a large unattended site doesn't OOM — // the cron will pick up where it left off on the next tick (within hours). $batch_cap = (int) apply_filters('mxchat_transcript_retention_batch_cap', 5000); $sessions_to_delete = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT session_id FROM {$transcripts_table} WHERE timestamp IS NOT NULL AND timestamp < %s LIMIT %d", $cutoff_date, $batch_cap ) ); if (empty($sessions_to_delete)) { return; } $placeholders = implode(',', array_fill(0, count($sessions_to_delete), '%s')); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // $placeholders is a server-built list of literal "%s" tokens. $deleted_transcripts = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$transcripts_table} WHERE session_id IN ($placeholders)", $sessions_to_delete ) ); if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $translations_table)) === $translations_table) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$translations_table} WHERE session_id IN ($placeholders)", $sessions_to_delete ) ); } if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $url_clicks_table)) === $url_clicks_table) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$url_clicks_table} WHERE session_id IN ($placeholders)", $sessions_to_delete ) ); } // phpcs:enable // The satisfaction rating and its feedback go with the conversation (79c1be). if (function_exists('mxchat_delete_session_ratings')) { mxchat_delete_session_ratings($sessions_to_delete); } update_option('mxchat_retention_last_swept_at', time(), false); update_option('mxchat_retention_rows_last_deleted', $deleted_transcripts, false); } public function export_chat_transcripts() { // A read: open to the read-only transcripts role (plan 93999a). if (!self::mxchat_can_view_transcripts()) { wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat')); } check_ajax_referer('mxchat_export_transcripts', 'security'); global $wpdb; $table_name = $wpdb->prefix . 'mxchat_chat_transcripts'; // Get all transcripts ordered by session and timestamp; id breaks ties so // a reply's text and html part saved in the same second keep their write // order (plan c09fbc, as in mxchat_fetch_conversation()). $results = $wpdb->get_results( "SELECT session_id, user_email, user_identifier, role, message, timestamp FROM {$table_name} ORDER BY session_id, timestamp ASC, id ASC" ); if (empty($results)) { wp_send_json_error(array('message' => 'No transcripts found.')); wp_die(); } // Set headers for CSV download header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="chat-transcripts-' . date('Y-m-d') . '.csv"'); header('Pragma: no-cache'); header('Expires: 0'); // Create output stream $output = fopen('php://output', 'w'); // Add UTF-8 BOM for proper Excel encoding fputs($output, "\xEF\xBB\xBF"); // Add CSV headers fputcsv($output, array( 'Session ID', 'Email', 'User Identifier', 'Role', 'Message', 'Timestamp' )); // Add data rows foreach ($results as $row) { fputcsv($output, array( $row->session_id, $row->user_email, $row->user_identifier, $row->role, $row->message, $row->timestamp )); } fclose($output); wp_die(); } // ============================================================================ // Leads tab (inside Transcripts) // // Leads are derived from existing data — no dedicated table. Primary source: // wp_mxchat_chat_transcripts rows where user_email is populated. Secondary // source: the mxchat_sessions table's visitor_email/visitor_name columns // (plus any unmigrated legacy `mxchat_email_{sid}` wp_options rows) for // "orphan" leads who submitted the pre-chat form but never chatted. // ============================================================================ /** * Fetch leads: dedup-by-email rows, stats strip, and top pages in one call. */ public function mxchat_fetch_leads() { // A read: open to the read-only transcripts role (plan 93999a). if (!self::mxchat_can_view_transcripts()) { wp_send_json_error(['message' => 'Insufficient permissions']); wp_die(); } $date_range = isset($_POST['date_range']) ? sanitize_key($_POST['date_range']) : 'all'; wp_send_json(self::mxchat_collect_leads([ 'page' => isset($_POST['page']) ? max(1, absint($_POST['page'])) : 1, 'per_page' => isset($_POST['per_page']) ? min(100, max(10, absint($_POST['per_page']))) : 25, 'search' => isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '', 'date_cutoff' => self::mxchat_leads_date_cutoff($date_range), 'status' => isset($_POST['status']) ? sanitize_key($_POST['status']) : 'all', 'page_url' => isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '', 'sort' => isset($_POST['sort']) ? sanitize_key($_POST['sort']) : 'last_seen', 'sort_dir' => (isset($_POST['sort_dir']) && $_POST['sort_dir'] === 'asc') ? 'asc' : 'desc', ])); wp_die(); } /** * Build the Leads payload: the transcripts-derived list plus the chat-deleted * and orphan buckets, paginated and hydrated exactly as the Leads tab shows it. * * Extracted from mxchat_fetch_leads() so the REST route (f958cf) reads leads * through the same query rather than growing a second one that would drift — * in particular from the address-less lead key introduced by c0cfaf. Callers * own their own capability check and their own per_page ceiling; this method * reads no superglobals and echoes nothing. * * @param array $args page, per_page, search, date_cutoff (MySQL datetime or ''), * status (all|orphan|chat_deleted), page_url, sort, sort_dir. * @return array */ public static function mxchat_collect_leads(array $args = []) { global $wpdb; $table = $wpdb->prefix . 'mxchat_chat_transcripts'; $page = max(1, (int) ($args['page'] ?? 1)); $per_page = max(1, (int) ($args['per_page'] ?? 25)); $offset = ($page - 1) * $per_page; $search = (string) ($args['search'] ?? ''); $status = (string) ($args['status'] ?? 'all'); $page_filter = (string) ($args['page_url'] ?? ''); $sort = (string) ($args['sort'] ?? 'last_seen'); $sort_dir = (isset($args['sort_dir']) && $args['sort_dir'] === 'asc') ? 'ASC' : 'DESC'; $date_cutoff = (string) ($args['date_cutoff'] ?? ''); $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'")); // Base WHERE for transcripts leads. // // Email is the row key whenever there IS one. With "Require Email Address" // turned off (c0cfaf) a capture can be name-only, and keying on email // would drop it from this list entirely — the lead would be collected and // then invisible. Those rows key on session id instead, written as // "session:" so the two kinds of key can never collide. // // The widening is gated on the option: while email is required (the // default, and every install that has not opted in) both the WHERE and the // GROUP BY reduce to exactly what they were before. $lead_key_sql = self::mxchat_leads_key_sql(); $email_optional = self::mxchat_leads_email_optional(); $where_clauses = $email_optional ? ["((user_email IS NOT NULL AND user_email != '') OR (user_name IS NOT NULL AND user_name != ''))"] : ["user_email IS NOT NULL", "user_email != ''"]; $where_params = []; if ($date_cutoff) { $where_clauses[] = 'timestamp >= %s'; $where_params[] = $date_cutoff; } if ($page_filter && $has_page_url_column) { $where_clauses[] = 'originating_page_url = %s'; $where_params[] = $page_filter; } if ($search !== '') { $like = '%' . $wpdb->esc_like($search) . '%'; $where_clauses[] = '(user_email LIKE %s OR user_name LIKE %s)'; $where_params[] = $like; $where_params[] = $like; } $where_sql = 'WHERE ' . implode(' AND ', $where_clauses); // Aggregate query grouped by the lead key (email, or session id when the // capture had no email). MAX(user_email) is safe as the display address: // rows sharing an email key all carry that same address, and rows keyed by // session have no address to disagree about. $select_sql = "SELECT {$lead_key_sql} AS lead_key, MAX(user_email) AS user_email, MAX(timestamp) AS last_seen, MIN(timestamp) AS first_seen, COUNT(DISTINCT session_id) AS conversation_count"; $order_column = in_array($sort, ['last_seen', 'conversation_count', 'first_seen'], true) ? $sort : 'last_seen'; $group_order_limit = " GROUP BY lead_key ORDER BY {$order_column} {$sort_dir} LIMIT %d OFFSET %d"; $transcripts_sql = $wpdb->prepare( "{$select_sql} FROM {$table} {$where_sql}{$group_order_limit}", array_merge($where_params, [$per_page, $offset]) ); $transcript_rows = $wpdb->get_results($transcripts_sql); // Count of unique transcript-based leads under the same filters. // With no date / page / search filter there is nothing to bind: the base WHERE is // static and $lead_key_sql comes from mxchat_leads_key_sql(), so prepare() would // only raise "must have a placeholder" on every Leads tab open (plan 95b2da). $count_sql = "SELECT COUNT(DISTINCT {$lead_key_sql}) FROM {$table} {$where_sql}"; if (!empty($where_params)) { $count_sql = $wpdb->prepare($count_sql, $where_params); } $transcripts_lead_count = (int) $wpdb->get_var($count_sql); // Hydrate each row: name, latest_session_id, top page. $leads = []; foreach ($transcript_rows as $row) { $detail = $has_page_url_column ? $wpdb->get_row($wpdb->prepare( "SELECT session_id, user_name, originating_page_url, originating_page_title FROM {$table} WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1", $row->lead_key )) : $wpdb->get_row($wpdb->prepare( "SELECT session_id, user_name FROM {$table} WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1", $row->lead_key )); $leads[] = [ 'lead_key' => $row->lead_key, 'email' => (string) $row->user_email, 'name' => isset($detail->user_name) ? (string) $detail->user_name : '', 'conversation_count' => (int) $row->conversation_count, 'last_seen' => $row->last_seen, 'last_seen_display' => self::mxchat_leads_format_relative($row->last_seen), 'first_seen' => $row->first_seen, 'latest_session_id' => isset($detail->session_id) ? $detail->session_id : '', 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '', 'top_page_title' => isset($detail->originating_page_title) ? $detail->originating_page_title : '', 'is_orphan' => false, 'status' => 'active', ]; } // Non-transcript lead sources. Built once here, then filtered/merged based on the // status filter below. Deduplication priority when the same email appears in multiple // sources: transcripts > chat_deleted > orphan. $transcripts_emails_seen = array_flip(array_map( function ($r) { return self::mxchat_leads_dedup_key($r); }, $leads )); // Chat-deleted leads: had a conversation that an admin removed. Preserved via // mxchat_lead_del_* options, with timestamps so they respect date filters. $chat_deleted_leads_all = []; if ($status === 'all' || $status === 'chat_deleted') { $chat_deleted_leads_all = self::mxchat_collect_chat_deleted_leads($search, $date_cutoff); // Dedup: drop any chat_deleted row whose email is already in the transcripts set. $chat_deleted_leads_all = array_values(array_filter( $chat_deleted_leads_all, function ($row) use ($transcripts_emails_seen) { return !isset($transcripts_emails_seen[self::mxchat_leads_dedup_key($row)]); } )); foreach ($chat_deleted_leads_all as $row) { $transcripts_emails_seen[self::mxchat_leads_dedup_key($row)] = true; } } // Orphans: pre-chat form captures with no conversation. No timestamp, so skipped // when a date filter is active. $orphan_leads_all = []; if (($status === 'all' || $status === 'orphan') && !$date_cutoff && !$page_filter) { $orphan_leads_all = self::mxchat_collect_orphan_leads($search); $orphan_leads_all = array_values(array_filter( $orphan_leads_all, function ($row) use ($transcripts_emails_seen) { return !isset($transcripts_emails_seen[self::mxchat_leads_dedup_key($row)]); } )); } // Apply status filter to the transcripts-derived list. if ($status === 'orphan' || $status === 'chat_deleted') { $leads = []; $transcripts_lead_count = 0; } // Stitch the current page from the three buckets in priority order. $total_count = $transcripts_lead_count + count($chat_deleted_leads_all) + count($orphan_leads_all); $remaining_slots = $per_page - count($leads); if ($remaining_slots > 0 && !empty($chat_deleted_leads_all)) { $start = max(0, ($page - 1) * $per_page - $transcripts_lead_count); if ($start < count($chat_deleted_leads_all)) { $leads = array_merge($leads, array_slice($chat_deleted_leads_all, $start, $remaining_slots)); $remaining_slots = $per_page - count($leads); } } if ($remaining_slots > 0 && !empty($orphan_leads_all)) { $before = $transcripts_lead_count + count($chat_deleted_leads_all); $start = max(0, ($page - 1) * $per_page - $before); if ($start < count($orphan_leads_all)) { $leads = array_merge($leads, array_slice($orphan_leads_all, $start, $remaining_slots)); } } $total_pages = $per_page > 0 ? (int) ceil($total_count / $per_page) : 1; // Stats strip: always computed over full dataset, unaffected by filters. $stats = self::mxchat_leads_stats($table, $has_page_url_column); // Top pages: top 5 by distinct emails captured. $top_pages = []; if ($has_page_url_column) { $top_pages_rows = $wpdb->get_results( "SELECT originating_page_url AS url, MAX(originating_page_title) AS title, COUNT(DISTINCT user_email) AS lead_count FROM {$table} WHERE user_email IS NOT NULL AND user_email != '' AND originating_page_url IS NOT NULL AND originating_page_url != '' GROUP BY originating_page_url ORDER BY lead_count DESC, url ASC LIMIT 5" ); foreach ($top_pages_rows as $p) { $top_pages[] = [ 'url' => $p->url, 'title' => $p->title ?: $p->url, 'lead_count' => (int) $p->lead_count, ]; } } // Consent state per lead (b062c4). Chat-deleted rows already carry it from // their preserved options; the rest read the newest session-store record // for that email. Empty string = NOT RECORDED (pre-feature capture or the // checkbox was off) — distinct from 'no', which is a recorded decline. $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent'); foreach ($leads as &$lead_consent_ref) { if (array_key_exists('consent', $lead_consent_ref)) { continue; } $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($lead_consent_ref['email']) : null; $lead_consent_ref['consent'] = $consent ? $consent['given'] : ''; $lead_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : ''; $lead_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : ''; } unset($lead_consent_ref); return [ 'success' => true, 'leads' => $leads, 'page' => $page, 'per_page' => $per_page, 'total_count' => $total_count, 'total_pages' => $total_pages, 'showing_start' => $total_count === 0 ? 0 : ($offset + 1), 'showing_end' => min($offset + $per_page, $total_count), 'stats' => $stats, 'top_pages' => $top_pages, ]; } /** * Delete one or more leads by email. Removes every transcripts row for that * email and cleans up related wp_options (mxchat_email_{sid}, mxchat_name_{sid}, * mxchat_history_{sid}) and any orphan option entries matching the email. */ public function mxchat_delete_leads() { // A write: administrators only, refused with 403 (plan 93999a). if (!current_user_can('manage_options')) { wp_send_json_error(['message' => 'Insufficient permissions'], 403); } check_ajax_referer('mxchat_delete_leads', 'security'); // The tab sends back the key it was given, which is an address for most // leads and "session:" for a name-only capture (c0cfaf). Splitting // here keeps sanitize_email() as the gate for the email half — an empty // or malformed value is dropped rather than turned into a WHERE clause // that would match every address-less row at once. $keys = self::mxchat_leads_split_keys( isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [] ); $emails = $keys['emails']; $sessions = $keys['sessions']; if (empty($emails) && empty($sessions)) { wp_send_json_error(['message' => 'No emails provided']); wp_die(); } $summary = ['deleted_sessions' => 0, 'deleted_rows' => 0]; if (!empty($emails)) { $summary = self::mxchat_wipe_leads_by_email($emails); } if (!empty($sessions)) { $session_summary = self::mxchat_wipe_leads_by_session($sessions); $summary['deleted_sessions'] += $session_summary['deleted_sessions']; $summary['deleted_rows'] += $session_summary['deleted_rows']; } wp_send_json([ 'success' => true, 'deleted_leads' => count($emails) + count($sessions), 'deleted_sessions' => $summary['deleted_sessions'], 'deleted_rows' => $summary['deleted_rows'], ]); wp_die(); } /** * Link-click rows (clicked URL, visitor IP and user agent) for these sessions, * removed the way the privacy eraser removes them (MxChat_Privacy:: * erase_session()), so every delete path takes the same set (C2, fixed on the * way from lane C3's read). * * @param string[] $session_ids Sanitized session ids. */ private static function mxchat_delete_session_link_clicks(array $session_ids) { global $wpdb; static $has_clicks = null; $session_ids = array_values(array_filter(array_map('strval', $session_ids), 'strlen')); if (empty($session_ids)) { return; } $table = $wpdb->prefix . 'mxchat_url_clicks'; if ($has_clicks === null) { $has_clicks = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table; } if (!$has_clicks) { return; } foreach ($session_ids as $sid) { $wpdb->delete($table, ['session_id' => $sid], ['%s']); } } /** * Wipe address-less leads by session id (c0cfaf) — the session-keyed twin of * mxchat_wipe_leads_by_email(). Same per-session option cleanup; no * email-keyed orphan sweep, because these leads have no address to sweep on. */ private static function mxchat_wipe_leads_by_session(array $session_ids) { global $wpdb; $table = $wpdb->prefix . 'mxchat_chat_transcripts'; $translations_table = $wpdb->prefix . 'mxchat_transcript_translations'; $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table; $deleted_sessions = 0; $deleted_rows = 0; foreach ($session_ids as $sid) { if ($sid === '') { continue; } $rows_removed = $wpdb->delete($table, ['session_id' => $sid], ['%s']); if ($rows_removed !== false) { $deleted_rows += (int) $rows_removed; } $deleted_sessions++; wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions'); delete_option('mxchat_history_' . $sid); delete_option('mxchat_email_' . $sid); delete_option('mxchat_name_' . $sid); delete_option('mxchat_agent_name_' . $sid); delete_option('mxchat_lead_del_email_' . $sid); delete_option('mxchat_lead_del_name_' . $sid); delete_option('mxchat_lead_del_ts_' . $sid); delete_option('mxchat_lead_del_consent_' . $sid); delete_option('mxchat_lead_del_consent_label_' . $sid); delete_option('mxchat_lead_del_consent_at_' . $sid); if (class_exists('MxChat_Session_Store')) { MxChat_Session_Store::delete_session($sid); // b64b77 } if ($has_translations) { $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']); } } // Ratings and link clicks go with their conversations, as the privacy // eraser and mxchat_wipe_leads_by_email() remove them (79c1be, C2). $session_ids = array_values(array_filter($session_ids, 'strlen')); if (!empty($session_ids) && function_exists('mxchat_delete_session_ratings')) { mxchat_delete_session_ratings($session_ids); } self::mxchat_delete_session_link_clicks($session_ids); return ['deleted_sessions' => $deleted_sessions, 'deleted_rows' => $deleted_rows]; } /** * Fully wipe one or more leads by email: every transcripts row, every related wp_options * entry (history, pre-chat capture, chat_deleted preservation, agent name, translations). * * Shared between the Leads-tab Delete button and the transcript-delete opt-in checkbox. * Input emails must already be sanitized with sanitize_email(). */ private static function mxchat_wipe_leads_by_email(array $emails) { global $wpdb; $table = $wpdb->prefix . 'mxchat_chat_transcripts'; $translations_table = $wpdb->prefix . 'mxchat_transcript_translations'; $has_translations = $wpdb->get_var("SHOW TABLES LIKE '$translations_table'") === $translations_table; $deleted_sessions = 0; $deleted_rows = 0; $emails_lc = array_map('strtolower', $emails); foreach ($emails as $email) { $session_ids = $wpdb->get_col($wpdb->prepare( "SELECT DISTINCT session_id FROM {$table} WHERE user_email = %s", $email )); $rows_removed = $wpdb->delete($table, ['user_email' => $email], ['%s']); if ($rows_removed !== false) { $deleted_rows += (int) $rows_removed; } foreach ($session_ids as $sid) { $deleted_sessions++; wp_cache_delete('chat_session_' . $sid, 'mxchat_chat_sessions'); delete_option('mxchat_history_' . $sid); delete_option('mxchat_email_' . $sid); delete_option('mxchat_name_' . $sid); delete_option('mxchat_agent_name_' . $sid); delete_option('mxchat_lead_del_email_' . $sid); delete_option('mxchat_lead_del_name_' . $sid); delete_option('mxchat_lead_del_ts_' . $sid); delete_option('mxchat_lead_del_consent_' . $sid); delete_option('mxchat_lead_del_consent_label_' . $sid); delete_option('mxchat_lead_del_consent_at_' . $sid); if (class_exists('MxChat_Session_Store')) { MxChat_Session_Store::delete_session($sid); // b64b77 } if ($has_translations) { $wpdb->delete($translations_table, ['session_id' => $sid], ['%s']); } } // Ratings go with their conversations (79c1be), and so do link clicks (C2). if (!empty($session_ids) && function_exists('mxchat_delete_session_ratings')) { mxchat_delete_session_ratings($session_ids); } self::mxchat_delete_session_link_clicks($session_ids); } // Table-side twin of the option sweep below (5658f2): orphan pre-chat // captures now live in the sessions table's identity columns. if (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'find_by_email')) { foreach ($emails as $email) { foreach (MxChat_Session_Store::find_by_email($email) as $sid) { MxChat_Session_Store::delete_session($sid); } } } // Clean up any lingering option entries (unmigrated orphan pre-chat captures + // chat_deleted preservations) whose stored value matches one of the emails being wiped. $lingering = $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'mxchat_email_%' OR option_name LIKE 'mxchat_lead_del_email_%'" ); foreach ($lingering as $opt) { if (!in_array(strtolower(trim($opt->option_value)), $emails_lc, true)) { continue; } if (strpos($opt->option_name, 'mxchat_lead_del_email_') === 0) { $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_')); delete_option('mxchat_lead_del_email_' . $sid); delete_option('mxchat_lead_del_name_' . $sid); delete_option('mxchat_lead_del_ts_' . $sid); delete_option('mxchat_lead_del_consent_' . $sid); delete_option('mxchat_lead_del_consent_label_' . $sid); delete_option('mxchat_lead_del_consent_at_' . $sid); } else { $sid = substr($opt->option_name, strlen('mxchat_email_')); delete_option('mxchat_email_' . $sid); delete_option('mxchat_name_' . $sid); } } wp_cache_delete('all_chat_sessions', 'mxchat_chat_sessions'); return [ 'deleted_sessions' => $deleted_sessions, 'deleted_rows' => $deleted_rows, ]; } /** * Stream a leads CSV. scope=all exports every lead under current filters is not * supported to keep semantics simple; caller either exports all leads or a * specific set of selected emails. */ public function mxchat_export_leads() { // A read: open to the read-only transcripts role (plan 93999a). if (!self::mxchat_can_view_transcripts()) { wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'mxchat')); } check_ajax_referer('mxchat_export_leads', 'security'); $scope = isset($_POST['scope']) ? sanitize_key($_POST['scope']) : 'all'; $fields_mode = isset($_POST['fields']) ? sanitize_key($_POST['fields']) : 'email_and_name'; // Selection keys arrive in the same shape the Leads tab renders them: // an address, or "session:" for a name-only capture (c0cfaf). $selection_keys = self::mxchat_leads_split_keys( isset($_POST['emails']) ? (array) wp_unslash($_POST['emails']) : [] ); $emails_in_clean = $selection_keys['emails']; $sessions_in = $selection_keys['sessions']; global $wpdb; $table = $wpdb->prefix . 'mxchat_chat_transcripts'; $has_page_url_column = !empty($wpdb->get_results("SHOW COLUMNS FROM $table LIKE 'originating_page_url'")); // Collect leads from transcripts. Keyed by email, or by session id when // the capture had no address (c0cfaf) — gated on the option, so with email // required this is the same query and the same rows as before. $lead_key_sql = self::mxchat_leads_key_sql(); $email_filter = self::mxchat_leads_email_optional() ? "((user_email IS NOT NULL AND user_email != '') OR (user_name IS NOT NULL AND user_name != ''))" : "user_email IS NOT NULL AND user_email != ''"; $transcripts_sql = "SELECT {$lead_key_sql} AS lead_key, MAX(user_email) AS email, MAX(timestamp) AS last_seen, COUNT(DISTINCT session_id) AS conversation_count FROM {$table} WHERE {$email_filter}"; $params = []; $selected_keys = array_merge( $emails_in_clean, array_map(function ($sid) { return 'session:' . $sid; }, $sessions_in) ); if ($scope === 'selected' && !empty($selected_keys)) { $placeholders = implode(',', array_fill(0, count($selected_keys), '%s')); $transcripts_sql .= " AND {$lead_key_sql} IN ({$placeholders})"; $params = $selected_keys; } $transcripts_sql .= " GROUP BY lead_key ORDER BY last_seen DESC"; $rows = !empty($params) ? $wpdb->get_results($wpdb->prepare($transcripts_sql, $params)) : $wpdb->get_results($transcripts_sql); // Hydrate each row with name + top page. $export_rows = []; foreach ($rows as $row) { $detail = $has_page_url_column ? $wpdb->get_row($wpdb->prepare( "SELECT user_name, originating_page_url FROM {$table} WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1", $row->lead_key )) : $wpdb->get_row($wpdb->prepare( "SELECT user_name FROM {$table} WHERE {$lead_key_sql} = %s ORDER BY timestamp DESC LIMIT 1", $row->lead_key )); $export_rows[] = [ 'email' => (string) $row->email, 'name' => isset($detail->user_name) ? (string) $detail->user_name : '', 'conversation_count' => (int) $row->conversation_count, 'last_seen' => $row->last_seen, 'top_page_url' => isset($detail->originating_page_url) ? $detail->originating_page_url : '', ]; } // Include orphan + chat_deleted leads when exporting all. if ($scope === 'all') { $transcripts_emails_lc = array_flip(array_map( function ($r) { return strtolower($r['email']); }, $export_rows )); foreach (self::mxchat_collect_chat_deleted_leads('') as $cd) { if (isset($transcripts_emails_lc[strtolower($cd['email'])])) continue; $transcripts_emails_lc[strtolower($cd['email'])] = true; $export_rows[] = [ 'email' => $cd['email'], 'name' => $cd['name'], 'conversation_count' => 0, 'last_seen' => $cd['last_seen'], 'top_page_url' => '', 'consent' => isset($cd['consent']) ? $cd['consent'] : '', 'consent_at' => isset($cd['consent_at']) ? $cd['consent_at'] : '', 'consent_label' => isset($cd['consent_label']) ? $cd['consent_label'] : '', ]; } foreach (self::mxchat_collect_orphan_leads('') as $orphan) { if (isset($transcripts_emails_lc[strtolower($orphan['email'])])) continue; $transcripts_emails_lc[strtolower($orphan['email'])] = true; $export_rows[] = [ 'email' => $orphan['email'], 'name' => $orphan['name'], 'conversation_count' => 0, 'last_seen' => '', 'top_page_url' => '', ]; } } if (empty($export_rows)) { wp_send_json_error(['message' => 'No leads to export.']); wp_die(); } // Consent columns (b062c4). Chat-deleted rows already carry the preserved // values; the rest read the newest session-store record for that email. // Empty consent = "not recorded" — a pre-feature capture, NOT a decline. $can_read_consent = class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'latest_consent'); foreach ($export_rows as &$export_consent_ref) { if (array_key_exists('consent', $export_consent_ref)) { continue; } $consent = $can_read_consent ? MxChat_Session_Store::latest_consent($export_consent_ref['email']) : null; $export_consent_ref['consent'] = $consent ? $consent['given'] : ''; $export_consent_ref['consent_at'] = $consent ? $consent['recorded_at'] : ''; $export_consent_ref['consent_label'] = $consent ? wp_strip_all_tags($consent['label']) : ''; } unset($export_consent_ref); $filename = 'mxchat-leads-' . date('Y-m-d') . '.csv'; header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Pragma: no-cache'); header('Expires: 0'); $output = fopen('php://output', 'w'); fputs($output, "\xEF\xBB\xBF"); // UTF-8 BOM for Excel if ($fields_mode === 'email_only') { fputcsv($output, ['Email']); foreach ($export_rows as $r) { fputcsv($output, [$r['email']]); } } else { fputcsv($output, ['Email', 'Name', 'Conversations', 'Last seen', 'Top page', 'Consent', 'Consent recorded', 'Consent text shown']); foreach ($export_rows as $r) { fputcsv($output, [ $r['email'], $r['name'], $r['conversation_count'], $r['last_seen'], $r['top_page_url'], $r['consent'] !== '' ? $r['consent'] : 'not recorded', $r['consent_at'], $r['consent_label'], ]); } } fclose($output); wp_die(); } /** * Stats strip payload (independent of filters). */ private static function mxchat_leads_stats($table, $has_page_url_column) { global $wpdb; $total_transcripts_emails = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT user_email) FROM {$table} WHERE user_email IS NOT NULL AND user_email != ''" ); $new_this_week = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM ( SELECT user_email FROM {$table} WHERE user_email IS NOT NULL AND user_email != '' GROUP BY user_email HAVING MIN(timestamp) >= %s ) AS new_leads", gmdate('Y-m-d H:i:s', strtotime('-7 days')) )); $total_convos = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE user_email IS NOT NULL AND user_email != ''" ); $orphan_count = count(self::mxchat_collect_orphan_leads('')); $chat_deleted_count = self::mxchat_count_chat_deleted_leads(); // Total leads = unique emails across all three sources (dedup priority: transcripts > chat_deleted > orphan // is already enforced at collection time in mxchat_fetch_leads; stats re-apply it here). $total_leads = $total_transcripts_emails + $chat_deleted_count + $orphan_count; $avg = $total_transcripts_emails > 0 ? round($total_convos / $total_transcripts_emails, 1) : 0; // Orphan % reflects *true* orphans only (pre-chat dropoffs). Chat-deleted leads are // excluded so the metric stays meaningful — admins shouldn't see their cleanups // inflate this number. $orphan_pct = $total_leads > 0 ? (int) round(($orphan_count / $total_leads) * 100) : 0; return [ 'total_leads' => $total_leads, 'new_this_week' => $new_this_week, 'avg_convos' => $avg, 'orphan_pct' => $orphan_pct, 'orphan_count' => $orphan_count, 'chat_deleted_count' => $chat_deleted_count, ]; } /** * Collect leads who had a conversation that an admin later deleted (preserved via * mxchat_lead_del_* options). Returns rows tagged status='chat_deleted' with the * original last-seen timestamp so they still sort and filter sensibly. * * @param string $search Optional email/name substring filter. * @param string $date_cutoff Optional 'Y-m-d H:i:s' cutoff — only rows with last_ts >= cutoff. * @return array */ private static function mxchat_collect_chat_deleted_leads($search = '', $date_cutoff = '') { global $wpdb; $table = $wpdb->prefix . 'mxchat_chat_transcripts'; $rows = $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'mxchat_lead_del_email_%'" ); if (empty($rows)) { return []; } // Emails that currently have transcripts rows should not appear as chat_deleted — // they've come back and chatted, so they're active leads again. $emails_in_transcripts = array_map( 'strtolower', (array) $wpdb->get_col( "SELECT DISTINCT user_email FROM {$table} WHERE user_email IS NOT NULL AND user_email != ''" ) ); $emails_in_transcripts = array_flip($emails_in_transcripts); $needle = strtolower(trim((string) $search)); $by_email = []; foreach ($rows as $opt) { $email = sanitize_email(trim((string) $opt->option_value)); if (!$email) { continue; } if (isset($emails_in_transcripts[strtolower($email)])) { continue; } $sid = substr($opt->option_name, strlen('mxchat_lead_del_email_')); if (!$sid) { continue; } $name = (string) get_option('mxchat_lead_del_name_' . $sid, ''); $ts = (string) get_option('mxchat_lead_del_ts_' . $sid, ''); if ($date_cutoff !== '' && ($ts === '' || $ts < $date_cutoff)) { continue; } if ($needle !== '') { $hay = strtolower($email . ' ' . $name); if (strpos($hay, $needle) === false) { continue; } } $key = strtolower($email); if (!isset($by_email[$key]) || (isset($by_email[$key]['last_seen']) && $ts > $by_email[$key]['last_seen'])) { // Consent proof preserved at chat-delete time (b062c4) — the // session row is gone, so these options are the only copy. $consent_state = (string) get_option('mxchat_lead_del_consent_' . $sid, ''); $by_email[$key] = [ 'email' => $email, 'name' => $name, 'conversation_count' => 0, 'last_seen' => $ts, 'last_seen_display' => $ts ? self::mxchat_leads_format_relative($ts) : __('Chat deleted', 'mxchat'), 'first_seen' => $ts, 'latest_session_id' => '', 'top_page_url' => '', 'top_page_title' => '', 'is_orphan' => false, 'status' => 'chat_deleted', 'consent' => $consent_state, 'consent_at' => $consent_state !== '' ? (string) get_option('mxchat_lead_del_consent_at_' . $sid, '') : '', 'consent_label' => $consent_state !== '' ? wp_strip_all_tags((string) get_option('mxchat_lead_del_consent_label_' . $sid, '')) : '', ]; } } // Newest chat_deleted first. usort($by_email, function ($a, $b) { return strcmp((string) $b['last_seen'], (string) $a['last_seen']); }); return array_values($by_email); } /** * Count unique emails preserved as "chat deleted" (for the stats strip). */ private static function mxchat_count_chat_deleted_leads() { global $wpdb; $table = $wpdb->prefix . 'mxchat_chat_transcripts'; $emails = $wpdb->get_col( "SELECT DISTINCT option_value FROM {$wpdb->options} WHERE option_name LIKE 'mxchat_lead_del_email_%'" ); if (empty($emails)) { return 0; } $transcripts_emails = array_map( 'strtolower', (array) $wpdb->get_col( "SELECT DISTINCT user_email FROM {$table} WHERE user_email IS NOT NULL AND user_email != ''" ) ); $transcripts_emails = array_flip($transcripts_emails); $count = 0; $seen = []; foreach ($emails as $raw) { $email = strtolower(trim((string) $raw)); if (!$email || isset($seen[$email]) || isset($transcripts_emails[$email])) { continue; } $seen[$email] = true; $count++; } return $count; } /** * Find leads who submitted the pre-chat form but never produced a transcripts row. * Returned rows have no conversation_count, no timestamp. * * @param string $search Optional email/name substring filter. * @return array */ private static function mxchat_collect_orphan_leads($search = '') { global $wpdb; $table = $wpdb->prefix . 'mxchat_chat_transcripts'; // Primary source since 5658f2: the sessions table's identity columns. // The legacy option scan stays for installs still mid-migration. // Ask for name-only sessions too when the owner made email optional // (c0cfaf) — that is the only source an address-less lead has. $store_rows = (class_exists('MxChat_Session_Store') && method_exists('MxChat_Session_Store', 'identity_rows')) ? MxChat_Session_Store::identity_rows(self::mxchat_leads_email_optional()) : []; $option_rows = $wpdb->get_results( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'mxchat_email_%'" ); if (empty($option_rows) && empty($store_rows)) { return []; } // Collect all session_ids that have real transcripts rows so we can exclude them. $session_ids_with_rows = $wpdb->get_col( "SELECT DISTINCT session_id FROM {$table} WHERE user_email IS NOT NULL AND user_email != ''" ); $session_ids_with_rows = array_flip($session_ids_with_rows); // Seen emails in transcripts (so orphans only include truly never-chatted leads). $emails_in_transcripts = array_map( 'strtolower', (array) $wpdb->get_col( "SELECT DISTINCT user_email FROM {$table} WHERE user_email IS NOT NULL AND user_email != ''" ) ); $emails_in_transcripts = array_flip($emails_in_transcripts); $orphans_by_email = []; $needle = strtolower(trim((string) $search)); // Normalize both sources into [sid, email, name] candidates. Table rows // first — a session can only live in one home, and dedup-by-email below // handles any overlap either way. $candidates = []; foreach ($store_rows as $row) { $candidates[] = [ 'sid' => (string) $row['session_id'], 'email' => (string) $row['visitor_email'], 'name' => is_string($row['visitor_name'] ?? null) ? $row['visitor_name'] : '', ]; } foreach ($option_rows as $opt) { $sid = substr($opt->option_name, strlen('mxchat_email_')); $name_option = $sid ? get_option('mxchat_name_' . $sid, '') : ''; $candidates[] = [ 'sid' => (string) $sid, 'email' => (string) $opt->option_value, 'name' => is_string($name_option) ? $name_option : '', ]; } // A capture with no address is an orphan too, and the commonest one: a // name-only lead (c0cfaf) has no transcript row until the visitor actually // sends a message, so this collector is the ONLY place it can surface. // Keying it on email would drop it silently — collected, then invisible. $email_optional = self::mxchat_leads_email_optional(); foreach ($candidates as $cand) { $email = sanitize_email(trim($cand['email'])); $name = trim($cand['name']); $sid = $cand['sid']; if (!$sid) { continue; } if (!$email) { // Address-less rows are only leads when the owner made email // optional; otherwise they are incomplete captures, and skipping // them keeps this list identical to what it has always shown. if (!$email_optional || $name === '') { continue; } } // Exclude leads who have any transcripts rows (they appear in the main list). if ($email && isset($emails_in_transcripts[strtolower($email)])) { continue; } if (isset($session_ids_with_rows[$sid])) { continue; } if ($needle !== '') { $hay = strtolower($email . ' ' . $name); if (strpos($hay, $needle) === false) { continue; } } $key = $email !== '' ? strtolower($email) : 'session:' . $sid; if (!isset($orphans_by_email[$key])) { $orphans_by_email[$key] = [ 'lead_key' => $key, 'email' => $email, 'name' => $name, 'conversation_count' => 0, 'last_seen' => '', 'last_seen_display' => __('No conversation yet', 'mxchat'), 'first_seen' => '', // An address-less orphan carries its session id: that is the // only handle the delete and export paths have on it. Rows // that DO have an address keep the empty value they always // had, so the "View convo" button stays hidden for them. 'latest_session_id' => $email !== '' ? '' : $sid, 'top_page_url' => '', 'top_page_title' => '', 'is_orphan' => true, 'status' => 'orphan', ]; } } return array_values($orphans_by_email); } /** * Is the pre-chat email field optional? (c0cfaf) * * When it is, a lead can exist with a name and no address, and every place * that treats email as the row key has to widen. Default ON — an install that * never touched the setting behaves exactly as it did before. */ private static function mxchat_leads_email_optional() { $options = get_option('mxchat_options', []); return is_array($options) && ($options['lead_capture_require_email_toggle'] ?? 'on') !== 'on'; } /** * SQL expression for a lead's key: its email address, or "session:" when * the capture had no address (c0cfaf). * * The literal prefix is what keeps the two key spaces apart — an address can * never contain a colon before its local part, so a session key can never be * mistaken for an email or vice versa. NULLIF collapses '' and NULL to the * same thing, since the column uses both for "no email given". * * Contains no user input: safe to interpolate into a prepared statement. */ private static function mxchat_leads_key_sql() { return "COALESCE(NULLIF(user_email, ''), CONCAT('session:', session_id))"; } /** * The PHP-side twin of mxchat_leads_key_sql(), used to dedupe rows arriving * from the three lead sources. Email keys stay lowercased, exactly as before, * so dedup behaviour for addressed leads is unchanged. */ private static function mxchat_leads_dedup_key($row) { $email = isset($row['email']) ? trim((string) $row['email']) : ''; if ($email !== '') { return strtolower($email); } if (!empty($row['lead_key'])) { return (string) $row['lead_key']; } return 'session:' . (isset($row['latest_session_id']) ? (string) $row['latest_session_id'] : ''); } /** * Split a list of client-supplied lead keys into real email addresses and * session keys. The Leads tab sends back whatever key it was given, so both * shapes arrive on the same parameter (c0cfaf). * * Anything that is neither a valid address nor a well-formed session key is * dropped — in particular an empty string, which as a bare email would match * every address-less row in the table at once. */ private static function mxchat_leads_split_keys(array $raw) { $emails = []; $sessions = []; foreach ($raw as $value) { $value = trim((string) $value); if ($value === '') { continue; } if (strpos($value, 'session:') === 0) { $sid = MxChat_Utils::sanitize_session_id(substr($value, 8)); if ($sid !== '' && $sid !== 'null') { $sessions[] = $sid; } continue; } $clean = sanitize_email($value); if ($clean) { $emails[] = $clean; } } return ['emails' => array_values(array_unique($emails)), 'sessions' => array_values(array_unique($sessions))]; } /** * Map a date_range key to a SQL-comparable cutoff string, or '' for all-time. */ private static function mxchat_leads_date_cutoff($date_range) { switch ($date_range) { case 'today': return gmdate('Y-m-d H:i:s', strtotime('-24 hours')); case '7d': return gmdate('Y-m-d H:i:s', strtotime('-7 days')); case '30d': return gmdate('Y-m-d H:i:s', strtotime('-30 days')); case '90d': return gmdate('Y-m-d H:i:s', strtotime('-90 days')); case 'all': default: return ''; } } /** * Turn a UTC timestamp into a short relative display like "2h ago" or "Apr 12". */ private static function mxchat_leads_format_relative($timestamp) { if (!$timestamp) { return ''; } $ts = strtotime($timestamp . ' UTC'); if (!$ts) { return ''; } $diff = time() - $ts; if ($diff < 60) return __('just now', 'mxchat'); if ($diff < 3600) return floor($diff / 60) . __('m ago', 'mxchat'); if ($diff < 86400) return floor($diff / 3600) . __('h ago', 'mxchat'); if ($diff < 604800) return floor($diff / 86400) . __('d ago', 'mxchat'); return wp_date('M j', $ts); } /** * Handle translation of chat messages via AJAX */ public function mxchat_translate_messages() { // A write (spends the site's API key, stores the result): administrators // only, refused with 403 (plan 93999a), and only from the Transcripts page // (M7b): no valid nonce, 403 and nothing translated. if (!current_user_can('manage_options')) { wp_send_json_error(['error' => 'Insufficient permissions'], 403); } check_ajax_referer('mxchat_translate_messages', 'security'); $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : ''; $target_lang = isset($_POST['target_lang']) ? sanitize_text_field($_POST['target_lang']) : 'en'; $messages_json = isset($_POST['messages']) ? wp_unslash($_POST['messages']) : '[]'; $messages = json_decode($messages_json, true); if (empty($session_id)) { wp_send_json_error(['error' => 'No session ID provided']); wp_die(); } if (empty($messages) || !is_array($messages)) { wp_send_json_error(['error' => 'No messages to translate']); wp_die(); } // Language names for prompting $languages = [ 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French', 'de' => 'German', 'it' => 'Italian', 'pt' => 'Portuguese', 'nl' => 'Dutch', 'ru' => 'Russian', 'zh' => 'Chinese', 'ja' => 'Japanese', 'ko' => 'Korean', 'ar' => 'Arabic', 'hi' => 'Hindi', 'tr' => 'Turkish', 'pl' => 'Polish', 'vi' => 'Vietnamese', 'th' => 'Thai', 'id' => 'Indonesian', 'sv' => 'Swedish', 'da' => 'Danish' ]; $target_lang_name = isset($languages[$target_lang]) ? $languages[$target_lang] : 'English'; // Build combined text for translation (numbered for parsing) $numbered_messages = []; foreach ($messages as $i => $msg) { $content = isset($msg['content']) ? trim($msg['content']) : ''; // Numbered by the message's own index, the number the reply is parsed // by below: the Transcripts view numbers rows, and a row that shows // nothing (plan e19ea8) leaves a gap. $n = isset($msg['index']) ? (int) $msg['index'] : $i; if (!empty($content)) { $numbered_messages[] = "[MSG" . $n . "]" . $content . "[/MSG" . $n . "]"; } } if (empty($numbered_messages)) { wp_send_json_error(['error' => 'No valid messages to translate']); wp_die(); } $combined_text = implode("\n\n", $numbered_messages); // Prepare the translation prompt $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."; // Get user's selected model and determine provider $options = get_option('mxchat_options', []); $selected_model = $options['model'] ?? 'gpt-5.6-sol'; // Check if using OpenRouter if ($selected_model === 'openrouter') { $provider = 'openrouter'; $selected_model = $options['openrouter_selected_model'] ?? ''; $api_key = $options['openrouter_api_key'] ?? ''; if (empty($selected_model)) { wp_send_json_error(['error' => 'No OpenRouter model selected']); wp_die(); } } else { // Determine provider from model name $model_parts = explode('-', $selected_model); $provider = strtolower($model_parts[0]); // Get the appropriate API key based on provider $api_key = ''; switch ($provider) { case 'gpt': case 'o1': $api_key = $options['api_key'] ?? ''; break; case 'claude': $api_key = $options['claude_api_key'] ?? ''; break; case 'grok': $api_key = $options['xai_api_key'] ?? ''; break; case 'deepseek': $api_key = $options['deepseek_api_key'] ?? ''; break; case 'gemini': $api_key = $options['gemini_api_key'] ?? ''; break; default: // Default to OpenAI $api_key = $options['api_key'] ?? ''; $provider = 'gpt'; break; } } if (empty($api_key)) { wp_send_json_error(['error' => 'No API key configured for ' . $provider]); wp_die(); } // Make API request based on provider $response = $this->translate_with_provider($provider, $selected_model, $api_key, $system_prompt, $combined_text); if (is_wp_error($response)) { wp_send_json_error(['error' => $response->get_error_message()]); wp_die(); } // Parse the response to extract translated messages $translations = []; foreach ($messages as $msg) { $index = $msg['index']; $pattern = '/\[MSG' . $index . '\](.*?)\[\/MSG' . $index . '\]/s'; if (preg_match($pattern, $response, $matches)) { $translations[] = [ 'index' => $index, 'translated' => trim($matches[1]) ]; } } // Save translations to database if (!empty($translations)) { $this->save_transcript_translation($session_id, $target_lang, $translations); } wp_send_json(['success' => true, 'translations' => $translations, 'language' => $target_lang]); wp_die(); } /** * Save transcript translation to database */ private function save_transcript_translation($session_id, $language_code, $translations) { global $wpdb; $table_name = $wpdb->prefix . 'mxchat_transcript_translations'; // Check if table exists, create if not if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) { mxchat_create_translations_table(); } $now = current_time('mysql'); $translations_json = wp_json_encode($translations); // Use REPLACE to insert or update $wpdb->query($wpdb->prepare( "REPLACE INTO $table_name (session_id, language_code, translations, created_at, updated_at) VALUES (%s, %s, %s, %s, %s)", $session_id, $language_code, $translations_json, $now, $now )); } /** * Get saved translation for a session */ public function mxchat_get_transcript_translation() { // A read of a translation already saved: open to the read-only // transcripts role (plan 93999a). if (!self::mxchat_can_view_transcripts()) { wp_send_json_error(['error' => 'Insufficient permissions']); wp_die(); } $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : ''; if (empty($session_id)) { wp_send_json_error(['error' => 'No session ID provided']); wp_die(); } global $wpdb; $table_name = $wpdb->prefix . 'mxchat_transcript_translations'; // Check if table exists if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") !== $table_name) { wp_send_json(['success' => true, 'has_translation' => false]); wp_die(); } // Get the most recent translation for this session $result = $wpdb->get_row($wpdb->prepare( "SELECT language_code, translations FROM $table_name WHERE session_id = %s ORDER BY updated_at DESC LIMIT 1", $session_id )); if ($result) { $translations = json_decode($result->translations, true); wp_send_json([ 'success' => true, 'has_translation' => true, 'language' => $result->language_code, 'translations' => $translations ]); } else { wp_send_json(['success' => true, 'has_translation' => false]); } wp_die(); } /** * Translate text using the user's selected provider and model */ private function translate_with_provider($provider, $model, $api_key, $system_prompt, $text) { switch ($provider) { case 'claude': return $this->translate_with_claude($api_key, $model, $system_prompt, $text); case 'grok': return $this->translate_with_xai($api_key, $model, $system_prompt, $text); case 'deepseek': return $this->translate_with_deepseek($api_key, $model, $system_prompt, $text); case 'gemini': return $this->translate_with_gemini($api_key, $model, $system_prompt, $text); case 'openrouter': return $this->translate_with_openrouter($api_key, $model, $system_prompt, $text); case 'gpt': case 'o1': default: return $this->translate_with_openai($api_key, $model, $system_prompt, $text); } } /** * Translate text using OpenAI API */ private function translate_with_openai($api_key, $model, $system_prompt, $text) { $response = wp_remote_post('https://api.openai.com/v1/chat/completions', [ 'timeout' => 60, 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json' ], 'body' => wp_json_encode([ 'model' => $model, 'messages' => [ ['role' => 'system', 'content' => $system_prompt], ['role' => 'user', 'content' => $text] ], 'temperature' => 0.3 ]) ]); if (is_wp_error($response)) { return $response; } $body = json_decode(wp_remote_retrieve_body($response), true); if (isset($body['error'])) { return new WP_Error('api_error', $body['error']['message']); } if (isset($body['choices'][0]['message']['content'])) { return $body['choices'][0]['message']['content']; } return new WP_Error('api_error', 'Invalid API response'); } /** * Translate text using Claude API */ private function translate_with_claude($api_key, $model, $system_prompt, $text) { // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15. // Read-time rescue: a saved retired id is remapped to the catalog's replacement before the API call. $model = MxChat_Model_Catalog::resolve_retired($model); // plan 8c4eb4: the catalog's one map $response = wp_remote_post('https://api.anthropic.com/v1/messages', [ 'timeout' => 60, 'headers' => [ 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01', 'Content-Type' => 'application/json' ], 'body' => wp_json_encode([ 'model' => $model, 'max_tokens' => 4096, // Prompt-cache breakpoint (plan 1ff43b): the translate instruction // repeats across every message in a batch, so cache reads bill at // 0.1x. Below the model's minimum prefix it silently no-ops. 'system' => trim((string) $system_prompt) === '' ? $system_prompt : [ ['type' => 'text', 'text' => $system_prompt, 'cache_control' => ['type' => 'ephemeral']] ], 'messages' => [ ['role' => 'user', 'content' => $text] ] ]) ]); if (is_wp_error($response)) { return $response; } $body = json_decode(wp_remote_retrieve_body($response), true); if (isset($body['error'])) { return new WP_Error('api_error', $body['error']['message']); } if (isset($body['content'][0]['text'])) { return $body['content'][0]['text']; } return new WP_Error('api_error', 'Invalid API response'); } /** * Translate text using xAI (Grok) API */ private function translate_with_xai($api_key, $model, $system_prompt, $text) { $response = wp_remote_post('https://api.x.ai/v1/chat/completions', [ 'timeout' => 60, 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json' ], 'body' => wp_json_encode([ 'model' => $model, 'messages' => [ ['role' => 'system', 'content' => $system_prompt], ['role' => 'user', 'content' => $text] ], 'temperature' => 0.3 ]) ]); if (is_wp_error($response)) { return $response; } $body = json_decode(wp_remote_retrieve_body($response), true); if (isset($body['error'])) { return new WP_Error('api_error', $body['error']['message']); } if (isset($body['choices'][0]['message']['content'])) { return $body['choices'][0]['message']['content']; } return new WP_Error('api_error', 'Invalid API response'); } /** * Translate text using DeepSeek API */ private function translate_with_deepseek($api_key, $model, $system_prompt, $text) { $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', [ 'timeout' => 60, 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json' ], 'body' => wp_json_encode([ 'model' => $model, 'messages' => [ ['role' => 'system', 'content' => $system_prompt], ['role' => 'user', 'content' => $text] ], 'temperature' => 0.3, // DeepSeek V4 defaults to thinking mode ON (temperature ignored, // slow responses); translation wants non-thinking. 'thinking' => ['type' => 'disabled'] ]) ]); if (is_wp_error($response)) { return $response; } $body = json_decode(wp_remote_retrieve_body($response), true); if (isset($body['error'])) { return new WP_Error('api_error', $body['error']['message']); } if (isset($body['choices'][0]['message']['content'])) { return $body['choices'][0]['message']['content']; } return new WP_Error('api_error', 'Invalid API response'); } /** * Translate text using Google Gemini API */ private function translate_with_gemini($api_key, $model, $system_prompt, $text) { if ($model === 'gemini-3-pro-preview') { $model = 'gemini-3.1-pro-preview'; } $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent'; $response = wp_remote_post($url, [ 'timeout' => 60, 'headers' => [ 'Content-Type' => 'application/json', 'x-goog-api-key' => $api_key, // plan f4c0bf: key in a header, never the URL ], 'body' => wp_json_encode([ 'contents' => [ [ 'parts' => [ ['text' => $system_prompt . "\n\n" . $text] ] ] ], 'generationConfig' => [ 'temperature' => 0.3 ] ]) ]); if (is_wp_error($response)) { return $response; } $body = json_decode(wp_remote_retrieve_body($response), true); if (isset($body['error'])) { return new WP_Error('api_error', $body['error']['message']); } // plan 781afd: join every text part (skip thought parts), not only parts[0]. $translated = ''; if (isset($body['candidates'][0]['content']['parts']) && is_array($body['candidates'][0]['content']['parts'])) { foreach ($body['candidates'][0]['content']['parts'] as $part) { if (is_array($part) && isset($part['text']) && is_string($part['text']) && empty($part['thought'])) { $translated .= $part['text']; } } } if (trim($translated) !== '') { return $translated; } $no_text_reason = isset($body['candidates'][0]['finishReason']) && is_string($body['candidates'][0]['finishReason']) ? $body['candidates'][0]['finishReason'] : (isset($body['promptFeedback']['blockReason']) && is_string($body['promptFeedback']['blockReason']) ? $body['promptFeedback']['blockReason'] : ''); return new WP_Error('api_error', $no_text_reason !== '' ? 'Gemini returned no text (' . $no_text_reason . ')' : 'Invalid API response'); } /** * Translate text using OpenRouter API */ private function translate_with_openrouter($api_key, $model, $system_prompt, $text) { $response = wp_remote_post('https://openrouter.ai/api/v1/chat/completions', [ 'timeout' => 60, 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', 'HTTP-Referer' => home_url(), 'X-Title' => 'MxChat Translation' ], 'body' => wp_json_encode([ 'model' => $model, 'messages' => [ ['role' => 'system', 'content' => $system_prompt], ['role' => 'user', 'content' => $text] ], 'temperature' => 0.3 ]) ]); if (is_wp_error($response)) { return $response; } $body = json_decode(wp_remote_retrieve_body($response), true); if (isset($body['error'])) { return new WP_Error('api_error', $body['error']['message']); } if (isset($body['choices'][0]['message']['content'])) { return $body['choices'][0]['message']['content']; } return new WP_Error('api_error', 'Invalid API response'); } public function mxchat_fetch_chat_history() { global $wpdb; $table_name = $wpdb->prefix . 'mxchat_chat_transcripts'; $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks'; // A read: open to the read-only transcripts role (plan 93999a). if (!self::mxchat_can_view_transcripts()) { wp_send_json_error(['message' => 'Insufficient permissions']); wp_die(); } $page = isset($_POST['page']) ? absint($_POST['page']) : 1; $per_page = isset($_POST['per_page']) ? absint($_POST['per_page']) : 50; $offset = ($page - 1) * $per_page; $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : ''; $sort_raw = isset($_POST['sort_order']) ? sanitize_key($_POST['sort_order']) : 'desc'; $allowed_sorts = array('asc', 'desc', 'rating_positive', 'rating_negative'); if (!in_array($sort_raw, $allowed_sorts, true)) { $sort_raw = 'desc'; } $sort_order = ($sort_raw === 'asc') ? 'ASC' : 'DESC'; $ratings_table = $wpdb->prefix . 'mxchat_session_ratings'; $ratings_join = ''; $rating_table_exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $ratings_table)) === $ratings_table); if ($rating_table_exists && ($sort_raw === 'rating_positive' || $sort_raw === 'rating_negative')) { // We sort by rating then recency. Wrap rating_value so NULL sorts last. $rating_dir = ($sort_raw === 'rating_positive') ? 'DESC' : 'ASC'; $ratings_join = " LEFT JOIN {$ratings_table} r ON r.session_id = t.session_id "; } // Build search condition $search_condition = ''; $search_params = []; if (!empty($search)) { $search_condition = "WHERE ( session_id LIKE %s OR user_email LIKE %s OR user_name LIKE %s OR user_identifier LIKE %s OR message LIKE %s )"; $search_params = array_fill(0, 5, '%' . $wpdb->esc_like($search) . '%'); } // Get total count $count_query = !empty($search) ? $wpdb->prepare("SELECT COUNT(DISTINCT session_id) FROM {$table_name} {$search_condition}", $search_params) : "SELECT COUNT(DISTINCT session_id) FROM {$table_name}"; $total_sessions = (int) $wpdb->get_var($count_query); // Get session IDs for current page. Default sort is recency; rating sorts join the ratings table // and order by rating value (NULLs last) with recency as tiebreaker. if ($ratings_join) { $order_by = "ORDER BY (r.rating_value IS NULL), r.rating_value {$rating_dir}, MAX(t.timestamp) DESC"; } else { $order_by = "ORDER BY MAX(t.timestamp) {$sort_order}"; } $session_query = !empty($search) ? $wpdb->prepare( "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} {$search_condition} GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d", array_merge($search_params, [$per_page, $offset]) ) : $wpdb->prepare( "SELECT DISTINCT t.session_id FROM {$table_name} t {$ratings_join} GROUP BY t.session_id {$order_by} LIMIT %d OFFSET %d", $per_page, $offset ); $session_ids = $wpdb->get_col($session_query); $total_pages = ceil($total_sessions / $per_page); if (empty($session_ids)) { wp_send_json([ 'success' => true, 'sessions' => [], 'page' => $page, 'total_pages' => 0, 'total_sessions' => 0, 'showing_start' => 0, 'showing_end' => 0 ]); wp_die(); } // Check for optional columns/tables $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table; $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'")); // Batch-fetch session ratings for this page (plan-a5b006). $ratings_map = array(); if ($rating_table_exists && !empty($session_ids)) { $placeholders = implode(',', array_fill(0, count($session_ids), '%s')); $rating_rows = $wpdb->get_results($wpdb->prepare( "SELECT session_id, rating_value, rating_feedback FROM {$ratings_table} WHERE session_id IN ($placeholders)", $session_ids )); foreach ($rating_rows as $row) { $ratings_map[$row->session_id] = array( 'value' => (int) $row->rating_value, 'feedback' => (string) $row->rating_feedback, ); } } // Build session list data $sessions = []; foreach ($session_ids as $session_id) { // Get session metadata $session_data = $originating_columns_exist ? $wpdb->get_row($wpdb->prepare( "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1", $session_id )) : $wpdb->get_row($wpdb->prepare( "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name} WHERE session_id = %s LIMIT 1", $session_id )); // Get message count and latest timestamp $message_stats = $wpdb->get_row($wpdb->prepare( "SELECT COUNT(*) as count, MAX(timestamp) as latest FROM {$table_name} WHERE session_id = %s", $session_id )); // Get first user message as preview $first_user_msg = $wpdb->get_var($wpdb->prepare( "SELECT message FROM {$table_name} WHERE session_id = %s AND role = 'user' ORDER BY timestamp ASC LIMIT 1", $session_id )); $preview = $first_user_msg ? wp_trim_words(wp_strip_all_tags(stripslashes($first_user_msg)), 12, '...') : 'No messages'; // Build display name $user_email = !empty($session_data->user_email) ? $session_data->user_email : ''; $user_name = !empty($session_data->user_name) ? $session_data->user_name : ''; $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest'; $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier); $display_sub = $user_email ?: ('ID: ' . $user_identifier); // Format time - show relative for recent, date for older $timestamp = strtotime($message_stats->latest . ' UTC'); $now = time(); $diff = $now - $timestamp; if ($diff < 3600) { $time_display = floor($diff / 60) . 'm ago'; } elseif ($diff < 86400) { $time_display = floor($diff / 3600) . 'h ago'; } elseif ($diff < 604800) { $time_display = floor($diff / 86400) . 'd ago'; } else { $time_display = wp_date('M j', $timestamp); } // Get initials for avatar $initials = strtoupper(substr($display_name, 0, 2)); if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) { $parts = explode(' ', $display_name); $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1)); } $rating_entry = isset($ratings_map[$session_id]) ? $ratings_map[$session_id] : null; $rating_value = $rating_entry ? $rating_entry['value'] : null; $rating_feedback = $rating_entry ? $rating_entry['feedback'] : ''; $sessions[] = [ 'session_id' => $session_id, 'display_name' => $display_name, 'display_sub' => $display_sub, 'initials' => $initials, 'preview' => $preview, 'message_count' => (int) $message_stats->count, 'time_display' => $time_display, 'timestamp' => $message_stats->latest, 'rating_value' => $rating_value, 'rating_feedback' => $rating_feedback, ]; } wp_send_json([ 'success' => true, 'sessions' => $sessions, 'page' => $page, 'total_pages' => $total_pages, 'total_sessions' => $total_sessions, 'showing_start' => $offset + 1, 'showing_end' => min($offset + $per_page, $total_sessions) ]); wp_die(); } /** * Fetch single conversation details for split-panel view */ public function mxchat_fetch_conversation() { global $wpdb; $table_name = $wpdb->prefix . 'mxchat_chat_transcripts'; $url_clicks_table = $wpdb->prefix . 'mxchat_url_clicks'; // A read: open to the read-only transcripts role (plan 93999a). if (!self::mxchat_can_view_transcripts()) { wp_send_json_error(['message' => 'Insufficient permissions']); wp_die(); } $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : ''; if (empty($session_id)) { wp_send_json_error(['message' => 'No session ID provided']); wp_die(); } // Check for optional columns/tables $url_table_exists = $wpdb->get_var("SHOW TABLES LIKE '$url_clicks_table'") === $url_clicks_table; $originating_columns_exist = !empty($wpdb->get_results("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'")); // Get session metadata $session_data = $originating_columns_exist ? $wpdb->get_row($wpdb->prepare( "SELECT user_email, user_name, user_identifier, originating_page_url, originating_page_title, timestamp FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC LIMIT 1", $session_id )) : $wpdb->get_row($wpdb->prepare( "SELECT user_email, user_name, user_identifier, timestamp FROM {$table_name} WHERE session_id = %s LIMIT 1", $session_id )); if (!$session_data) { wp_send_json_error(['message' => 'Session not found']); wp_die(); } // Get clicked URLs $clicked_urls = []; if ($url_table_exists) { $url_clicks = $wpdb->get_results($wpdb->prepare( "SELECT DISTINCT clicked_url FROM {$url_clicks_table} WHERE session_id = %s ORDER BY click_timestamp ASC", $session_id )); foreach ($url_clicks as $click) { $clicked_urls[] = $click->clicked_url; } } // Get all messages. Rows saved in the same second (a reply's text and its // html part) keep the order they were written in (plan e19ea8). $messages = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$table_name} WHERE session_id = %s ORDER BY timestamp ASC, id ASC", $session_id )); // Build user info $user_email = !empty($session_data->user_email) ? $session_data->user_email : ''; $user_name = !empty($session_data->user_name) ? $session_data->user_name : ''; $user_identifier = !empty($session_data->user_identifier) ? $session_data->user_identifier : 'Guest'; $display_name = $user_name ?: ($user_email ? explode('@', $user_email)[0] : $user_identifier); $display_sub = $user_email ?: ('ID: ' . $user_identifier); // The guest's address, when that is what the identifier holds (79c1be). // A logged-in visitor is identified by their login, so the drawer's IP // row stays hidden for them. $user_ip = filter_var($user_identifier, FILTER_VALIDATE_IP) ? $user_identifier : ''; // Get initials $initials = strtoupper(substr($display_name, 0, 2)); if (strlen($display_name) > 2 && strpos($display_name, ' ') !== false) { $parts = explode(' ', $display_name); $initials = strtoupper(substr($parts[0], 0, 1) . substr(end($parts), 0, 1)); } // Page info $page_url = $originating_columns_exist && !empty($session_data->originating_page_url) ? $session_data->originating_page_url : ''; $page_title = ''; if ($page_url) { $page_title = !empty($session_data->originating_page_title) ? $session_data->originating_page_title : parse_url($page_url, PHP_URL_PATH); } // Format messages for output $transcript_allowed_html = [ 'b' => [], 'strong' => [], 'i' => [], 'em' => [], 'u' => [], 'br' => [], 'p' => [], 'ul' => [], 'ol' => [], 'li' => [], 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => []], 'div' => ['class' => [], 'id' => [], 'data-nonce' => []], 'img' => ['src' => [], 'alt' => [], 'class' => []], 'h3' => ['class' => []], 'h4' => ['class' => []], 'button' => ['type' => [], 'class' => [], 'data-product-id' => [], 'data-nonce' => [], 'data-product-type' => [], 'data-original-text' => [], 'data-mxchat-action' => []], 'select' => ['class' => [], 'data-attribute' => []], 'option' => ['value' => []], 'span' => ['class' => []], 'del' => [], 'ins' => [], ]; // plan 9ddc82: a form the bot showed (mxchat-forms: labels, inputs, // textareas, selects) is shown with its fields, so the admin sees what the // visitor saw. Bot and agent rows only; no name, id, form or event // attributes, hidden inputs dropped, and every control disabled below. $form_allowed_html = [ 'label' => ['class' => []], 'input' => ['type' => [], 'class' => [], 'placeholder' => [], 'value' => [], 'checked' => []], 'textarea' => ['class' => [], 'placeholder' => [], 'rows' => []], 'option' => ['value' => [], 'selected' => []], ]; // format_transcript_message() turns URLs and [text](url) into links with // regexes that cannot tell text from an attribute value, so a URL inside an // allowed attribute (a visitor's span class, an AI reply's title) came out // as a link whose quotes closed that attribute. The formatted message is // filtered again, with the tags the formatter itself writes added, and its // tags balanced so a message can never close the bubble it is shown in. $formatted_allowed_html = array_merge($transcript_allowed_html, [ 'a' => ['href' => [], 'title' => [], 'target' => [], 'class' => [], 'rel' => []], 'h1' => [], 'h2' => [], 'h5' => [], 'h6' => [], 'pre' => [], 'code' => ['class' => []], ]); $formatted_messages = []; // plan 705b6c: per-reply usage and the chat's total. Rows saved before // 3.2.26 (and replies no AI provider wrote) carry none: no line, no total. $usage_total = ['in' => null, 'cached' => null, 'out' => null, 'replies' => 0]; foreach ($messages as $msg) { $is_user = ($msg->role === 'user'); // Live-agent replies (Slack/Telegram handoff) persist with role 'agent'. // Kept OUT of $is_bot so the RAG Sources link below stays bot-only. $is_agent = ($msg->role === 'agent'); $is_bot = ($msg->role === 'bot' || $msg->role === 'assistant'); if ($is_user) { $content = wp_kses(stripslashes($msg->message), $transcript_allowed_html); $formatted_content = force_balance_tags(wp_kses($this->format_transcript_message($content), $formatted_allowed_html)); } else { $content = wp_kses(stripslashes($msg->message), array_merge($transcript_allowed_html, $form_allowed_html)); $formatted_content = self::mxchat_transcript_readonly_controls(force_balance_tags(wp_kses($this->format_transcript_message($content), array_merge($formatted_allowed_html, $form_allowed_html)))); } $has_rag = $is_bot && !empty($msg->rag_context); $usage = null; $retrieval_usage = null; if ($is_bot) { $u_in = isset($msg->tokens_in) ? (int) $msg->tokens_in : null; $u_cached = isset($msg->tokens_cached) ? (int) $msg->tokens_cached : null; $u_out = isset($msg->tokens_out) ? (int) $msg->tokens_out : null; if ($u_in !== null || $u_out !== null) { $usage = [ 'model' => isset($msg->model) ? (string) $msg->model : '', 'in' => $u_in, 'cached' => $u_cached, 'out' => $u_out, 'label' => $this->mxchat_transcript_usage_label($u_in, $u_cached, $u_out), ]; foreach (['in' => $u_in, 'cached' => $u_cached, 'out' => $u_out] as $k => $v) { if ($v !== null) { $usage_total[$k] = (int) $usage_total[$k] + $v; } } $usage_total['replies']++; } // plan 65366b: tokens of the calls made to find the answer (query // embeddings since 93f0bd, vector store file_search, search query // rewrite), shown apart from the // reply's own numbers, which stay as they are. $r_in = isset($msg->retrieval_tokens_in) ? (int) $msg->retrieval_tokens_in : null; $r_out = isset($msg->retrieval_tokens_out) ? (int) $msg->retrieval_tokens_out : null; if ($r_in !== null || $r_out !== null) { $retrieval_usage = [ 'in' => $r_in, 'out' => $r_out, 'label' => $this->mxchat_transcript_usage_label($r_in, null, $r_out), ]; /* translators: %s: token counts such as "2,134 in, 85 out" */ $retrieval_usage['text'] = sprintf(__('plus retrieval/search %s', 'mxchat'), $retrieval_usage['label']); $retrieval_usage['title'] = __("Tokens the AI provider reported for finding the answer before this reply: turning the visitor's question into an embedding for the knowledge base search, the OpenAI Vector Store search, rewriting the visitor's words into a search query, and the product searches an add-on such as WooCommerce ran for this turn. Not included in the reply's own numbers.", 'mxchat'); // embedding added by plan 93f0bd, add-on product searches by 85d8b7 (97a730) } } // plan e19ea8: whether this bot row is a reply's html part, read from // the stored markup (the filtered content has lost its scripts' tags // and hidden attributes). The Transcripts view joins a part to the // reply before it, as the live chat showed it; a part that renders // nothing (the Add to Cart script, the cart signal) is not shown. $part = self::mxchat_transcript_part_kind($msg->role, $msg->message); $row_ts = strtotime($msg->timestamp . ' UTC'); $formatted_messages[] = [ 'id' => $msg->id, 'role' => $msg->role, 'is_user' => $is_user, 'is_agent' => $is_agent, 'is_bot' => $is_bot, 'content' => ($part === 'none') ? '' : $formatted_content, 'timestamp' => wp_date('g:i A', $row_ts), 'full_timestamp' => wp_date('F j, Y g:i A', $row_ts), 'ts' => $row_ts ? (int) $row_ts : 0, 'part' => $part, 'has_rag' => $has_rag, 'usage' => $usage, 'retrieval_usage' => $retrieval_usage ]; } $usage_summary = null; if ($usage_total['replies'] > 0) { $usage_summary = [ 'in' => $usage_total['in'], 'cached' => $usage_total['cached'], 'out' => $usage_total['out'], 'replies' => $usage_total['replies'], 'label' => $this->mxchat_transcript_usage_label($usage_total['in'], $usage_total['cached'], $usage_total['out']), /* translators: %s: number of AI replies that recorded token usage */ 'title' => html_entity_decode(sprintf(_n('Tokens the AI provider reported for %s reply in this chat', 'Tokens the AI provider reported for %s replies in this chat', $usage_total['replies'], 'mxchat'), number_format_i18n($usage_total['replies'])), ENT_QUOTES, 'UTF-8'), ]; } // First message timestamp for "started" display $started = !empty($messages) ? wp_date('M j, Y g:i A', strtotime($messages[0]->timestamp . ' UTC')) : '-'; // Pull rating_feedback for this session (if any) so the details drawer can show it. $rating_feedback = ''; $ratings_table_det = $wpdb->prefix . 'mxchat_session_ratings'; if ($wpdb->get_var("SHOW TABLES LIKE '$ratings_table_det'") === $ratings_table_det) { $rating_feedback = (string) $wpdb->get_var($wpdb->prepare( "SELECT rating_feedback FROM {$ratings_table_det} WHERE session_id = %s LIMIT 1", $session_id )); } wp_send_json([ 'success' => true, 'session_id' => $session_id, 'user' => [ 'name' => $display_name, 'sub' => $display_sub, 'initials' => $initials, 'email' => $user_email, 'identifier' => $user_identifier, 'ip' => $user_ip ], 'page' => [ 'url' => $page_url, 'title' => $page_title ], 'clicked_urls' => $clicked_urls, 'messages' => $formatted_messages, 'message_count' => count($messages), 'started' => $started, 'rating_feedback' => $rating_feedback, 'usage_total' => $usage_summary ]); wp_die(); } /** * plan 9ddc82: form controls in a filtered transcript message, read-only. * Hidden inputs are removed (the visitor never saw them), and every input, * textarea and select is disabled, so nothing in Transcripts can be typed * into, toggled or submitted. Runs on markup wp_kses() has already * normalised, so the tag names are lower case and attributes quoted. * * @param string $html Filtered message markup. * @return string */ private static function mxchat_transcript_readonly_controls($html) { if (strpos($html, ']*\btype\s*=\s*(["\'])\s*hidden\s*\1[^>]*>/i', '', $html); return preg_replace('/<(input|textarea|select)\b/i', '<$1 disabled', $html); } /** * plan e19ea8: what a stored row is to the Transcripts view. The server * stores a reply's text and its html part (product cards, a gallery, a * form) as two rows; the live chat showed them as one message. Same test * as the widget's replay (chat-script.js mxchatIsStoredHtmlPart and * mxchatHtmlHasVisibleContent), so both views join the same rows. * * @param string $role Row role. * @param string $message Stored message, unfiltered. * @return string '' (not an html part), 'html', 'embed' (a YouTube embed, * its own message) or 'none' (renders nothing: a script, a * hidden element; not shown). Public so other transcript * outputs can make the same call. */ public static function mxchat_transcript_part_kind($role, $message) { if ($role !== 'bot' || !is_string($message) || $message === '') { return ''; } $part = html_entity_decode(str_replace(array("\\'", '\\"'), array("'", '"'), $message), ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (preg_replace('/^[\s\x{00A0}]+|[\s\x{00A0}]+$/u', '', $part) === '') { return ''; } $is_part = preg_match('/<[a-z][^>]*class\s*=\s*["\'][^"\']*\bmxchat-/i', $part) || strpos($part, '