PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | includes/class-mxchat-public.php +251 -12 3.2.20 → 3.2.25 View file →
@@ -11,8 +11,23 @@
11 11 // yields, so an explicit floating placement never doubles up with the
12 12 // Auto-Display instance (plan 95dd1e).
13 13 private static $floating_instance_rendered = false;
14 14
15 + // Bot ids already placed on this page (plan 977cad), floating and inline
16 + // alike. Two copies of one bot share every element id (chat-input-<bot>,
17 + // send-button-<bot>, ...) and chat-script.js looks elements up by id, so
18 + // the second copy's Send reads the FIRST copy's empty box and sends
19 + // nothing. One copy of a bot per page: append_chatbot_to_body() stands
20 + // down for a bot the content already shows, and a second explicit
21 + // placement of the same bot renders an administrator-only note instead.
22 + // Only page renders count: a render in wp-admin, admin-ajax, cron, REST,
23 + // WP-CLI, a feed, or inside wp_head (a plugin building meta tags from the
24 + // content) is not on the page and must not suppress the real one. Note
25 + // that a block theme renders the body BEFORE wp_head (template-canvas
26 + // builds the template first so blocks can enqueue), so "after wp_head"
27 + // is not the test.
28 + private static $rendered_bot_ids = array();
29 +
15 30 public function __construct() {
16 31 // Simply get the options without defining duplicated defaults
17 32 $this->options = get_option('mxchat_options', array());
18 33 add_shortcode('mxchat_chatbot', array($this, 'render_chatbot_shortcode'));
@@ -79,8 +94,17 @@
79 94 // Don't show chatbot if determination is false
80 95 if ($bot_to_show === false) {
81 96 return;
82 97 }
98 +
99 + // The bot is already on this page as an explicit placement (an inline
100 + // shortcode or block in the content). A second, floating copy would share
101 + // its element ids and could be opened but not send; the embedded one wins
102 + // (plan 977cad). A page whose embedded chatbot is a DIFFERENT bot keeps
103 + // both, their ids differ.
104 + if (self::is_bot_on_page($bot_to_show)) {
105 + return;
106 + }
83 107
84 108 // Handle consent for display
85 109 $consent_category = 'marketing';
86 110 $has_consent = true;
@@ -102,8 +126,56 @@
102 126 echo do_shortcode('[mxchat_chatbot floating="yes" has_consent="' . ($has_consent ? 'yes' : 'no') . '"]');
103 127 }
104 128 }
105 129
130 + /**
131 + * Is this render on the page a visitor sees? (plan 977cad) False in
132 + * wp-admin, admin-ajax, cron, REST, WP-CLI, a feed, and inside wp_head.
133 + * Not "after wp_head": block themes render the body content first.
134 + */
135 + private static function is_page_render() {
136 + if (is_admin() || wp_doing_ajax() || wp_doing_cron()
137 + || (defined('REST_REQUEST') && REST_REQUEST)
138 + || (defined('WP_CLI') && WP_CLI)) {
139 + return false;
140 + }
141 + return !doing_action('wp_head') && !is_feed();
142 + }
143 +
144 + private static function record_bot_on_page($bot_id) {
145 + if (!self::is_page_render()) {
146 + return;
147 + }
148 + $bot_id = sanitize_key($bot_id);
149 + if ($bot_id !== '' && !in_array($bot_id, self::$rendered_bot_ids, true)) {
150 + self::$rendered_bot_ids[] = $bot_id;
151 + }
152 + }
153 +
154 + /**
155 + * Has a placement of this bot already rendered in the page body on this
156 + * request? (plan 977cad)
157 + */
158 + public static function is_bot_on_page($bot_id) {
159 + if (!self::is_page_render()) {
160 + return false;
161 + }
162 + return in_array(sanitize_key($bot_id), self::$rendered_bot_ids, true);
163 + }
164 +
165 + /**
166 + * What a second placement of an already-shown bot renders: nothing for
167 + * visitors, one line for a logged-in administrator (plan 977cad).
168 + */
169 + private static function duplicate_placement_notice() {
170 + if (!current_user_can('manage_options')) {
171 + return '';
172 + }
173 + return '<p class="mxchat-placement-notice" data-nosnippet>'
174 + . esc_html__('MxChat: this page already shows this chatbot, so a second copy of the same bot is not displayed. Only administrators see this note.', 'mxchat')
175 + . '</p>';
176 + }
177 +
106 178 private function mxchat_get_user_identifier() {
107 179 return sanitize_text_field($_SERVER['REMOTE_ADDR']);
108 180 }
109 181
@@ -132,9 +204,18 @@
132 204 'bot_id' => '' // Support for multi-bot functionality - empty means auto-detect
133 205 ), $atts);
134 206
135 207 // Determine which bot to use
136 - $bot_id = $this->determine_bot_for_shortcode($attributes['bot_id']);
208 + $bot_id = sanitize_key($this->determine_bot_for_shortcode($attributes['bot_id']));
209 +
210 + // A second placement of a bot already on this page is not rendered
211 + // (plan 977cad, see $rendered_bot_ids): it could be opened but never
212 + // sent. Administrators get a one-line note in its place so the
213 + // duplicate is visible to the person who can remove it.
214 + if (self::is_bot_on_page($bot_id)) {
215 + return self::duplicate_placement_notice();
216 + }
217 + self::record_bot_on_page($bot_id);
137 218
138 219 // A floating shortcode always renders; what it does is suppress the LATER
139 220 // auto-append instance (see append_chatbot_to_body), so a page never gets
140 221 // two floating widgets. This replaced a dead empty branch here that
@@ -146,9 +227,8 @@
146 227 // Non-floating shortcodes (floating="no") should NEVER be blocked by the hide setting
147 228 // This allows embedded chatbots even when floating is hidden
148 229
149 230 $is_floating = $attributes['floating'] === 'yes';
150 - $bot_id = sanitize_key($bot_id); // Sanitize bot ID
151 231
152 232 // Rest of your existing shortcode rendering logic continues unchanged...
153 233 // [All the existing HTML generation code remains the same]
154 234
@@ -232,8 +312,29 @@
232 312 $name_field_placeholder = isset($current_options['name_field_placeholder']) ?
233 313 esc_attr($current_options['name_field_placeholder']) :
234 314 esc_attr__('Enter your name', 'mxchat');
235 315
316 + // Require Email Address (c0cfaf). Default ON — absent key means required,
317 + // so existing installs render the same markup they always have.
318 + $require_email_field = ($current_options['lead_capture_require_email_toggle'] ?? 'on') === 'on';
319 +
320 + // Pre-fill the form from the profile when a signed-in visitor is being
321 + // shown it (c0cfaf). Emitted ONLY for a logged-in request, so the markup a
322 + // guest gets — the copy a page cache actually stores and re-serves — never
323 + // carries anyone's address. WordPress already marks logged-in responses
324 + // uncacheable, and such a page carries per-user data from other plugins
325 + // regardless.
326 + $prefill_email_attr = '';
327 + $prefill_name_attr = '';
328 + if (is_user_logged_in()) {
329 + if ($user_email !== '') {
330 + $prefill_email_attr = ' value="' . esc_attr($user_email) . '"';
331 + }
332 + if ($user_name !== '') {
333 + $prefill_name_attr = ' value="' . esc_attr($user_name) . '"';
334 + }
335 + }
336 +
236 337 // Consent checkbox (b062c4). Default OFF — with the toggle off this block
237 338 // renders nothing and the form markup is byte-identical to before.
238 339 $enable_consent_checkbox = isset($current_options['enable_consent_checkbox']) &&
239 340 ($current_options['enable_consent_checkbox'] === '1' || $current_options['enable_consent_checkbox'] === 'on');
@@ -310,13 +411,17 @@
310 411
311 412 // Add name field if enabled
312 413 if ($enable_name_field) {
313 414 echo ' <label for="user-name-' . esc_attr($bot_id) . '" class="sr-only mxchat-name-label">' . esc_html__('Name', 'mxchat') . '</label>';
314 - echo ' <input type="text" id="user-name-' . esc_attr($bot_id) . '" name="user_name" class="mxchat-name-input" required placeholder="' . $name_field_placeholder . '" />';
415 + echo ' <input type="text" id="user-name-' . esc_attr($bot_id) . '" name="user_name" class="mxchat-name-input" required' . $prefill_name_attr . ' placeholder="' . $name_field_placeholder . '" />';
315 416 }
316 417
418 + // The email field drops its required attribute when the owner
419 + // has made it optional (c0cfaf). Like the consent checkbox,
420 + // the attribute is UX only — the save endpoint re-enforces the
421 + // rule server-side, so a direct POST cannot bypass it.
317 422 echo ' <label for="user-email-' . esc_attr($bot_id) . '" class="sr-only">' . esc_html__('Email Address', 'mxchat') . '</label>';
318 - echo ' <input type="email" id="user-email-' . esc_attr($bot_id) . '" name="user_email" class="mxchat-email-input" required placeholder="' . esc_attr__('Enter your email address', 'mxchat') . '" />';
423 + echo ' <input type="email" id="user-email-' . esc_attr($bot_id) . '" name="user_email" class="mxchat-email-input"' . ($require_email_field ? ' required' : '') . $prefill_email_attr . ' placeholder="' . ($require_email_field ? esc_attr__('Enter your email address', 'mxchat') : esc_attr__('Enter your email address (optional)', 'mxchat')) . '" />';
319 424
320 425 // Consent checkbox row (b062c4). The native required attribute
321 426 // is UX only — the save endpoint re-enforces it server-side.
322 427 // Clicking an anchor inside the label activates the link, not
@@ -351,9 +456,15 @@
351 456 // additions-only + non-atomic, with aria-busy held on the streaming
352 457 // bubble until [DONE] (chat-script.js) so a streamed reply is
353 458 // announced once, complete — not per token (WCAG 4.1.3, plan 67f126).
354 459 echo ' <div id="chat-box-' . esc_attr($bot_id) . '" class="chat-box" role="log" aria-live="polite" aria-atomic="false" aria-relevant="additions text" aria-label="' . esc_attr__('Chat messages', 'mxchat') . '">';
355 - echo ' <div class="bot-message"' . ($skip_inline_colors ? '' : ' style="background: ' . esc_attr($bot_message_bg_color) . ';"') . '>';
460 + // mxchat-intro-message marks the greeting bubble so nothing has to
461 + // infer it from position (plan a1a79b). Only this element ever
462 + // carries the class — reset and the {visitor_name} substitution
463 + // both key off it, and both used to assume the greeting was still
464 + // the first .bot-message in the box, which stops being true the
465 + // moment chat persistence rehydrates the transcript.
466 + echo ' <div class="bot-message mxchat-intro-message"' . ($skip_inline_colors ? '' : ' style="background: ' . esc_attr($bot_message_bg_color) . ';"') . '>';
356 467 echo ' <div dir="auto"' . ($skip_inline_colors ? '' : ' style="color: ' . esc_attr($bot_message_font_color) . ';"') . '>';
357 468 echo wp_kses_post($intro_message);
358 469 echo ' </div>';
359 470 echo ' </div>';
@@ -494,14 +605,22 @@
494 605 echo ' <path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" style="fill: none !important;"/>';
495 606 echo ' <circle cx="12" cy="13" r="3" style="fill: none !important;"/>';
496 607 echo ' </svg>';
497 608 echo ' </button>';
498 -
499 -
609 +
610 + // Toolbar items API (plan 65c9b6). Add-ons put their buttons here
611 + // through the mxchat_chat_toolbar_items filter instead of polling
612 + // the DOM after load. Only asked for when the toolbar is on: an item
613 + // nobody can see is work nobody should do (the Woo cart badge used
614 + // to fetch a count for a hidden toolbar on every page view).
615 + if (isset($this->options['chat_toolbar_toggle']) && $this->options['chat_toolbar_toggle'] === 'on') {
616 + echo self::render_toolbar_items($bot_id);
617 + }
618 +
500 619 echo ' </div>';
501 -
620 +
502 621 echo ' <div class="chatbot-footer">';
503 -
622 +
504 623 // Output the privacy notice if enabled
505 624 if ($privacy_toggle && !empty($privacy_text)) {
506 625 echo '<p class="privacy-notice">' . $privacy_text . '</p>';
507 626 }
@@ -547,10 +666,116 @@
547 666 return ob_get_clean();
548 667 }
549 668
550 669 /**
670 + * Render the add-on items of the chat toolbar (plan-mxchat-20260916-65c9b6).
671 + *
672 + * `mxchat_chat_toolbar_items` receives an empty array and the bot id and
673 + * returns items shaped ['id' => 'woo-cart', 'html' => '<button …>…</button>',
674 + * 'order' => 10]. Items are sorted by order (ties keep filter order), keyed
675 + * by id so a later callback can replace an earlier one, and the html is run
676 + * through wp_kses with the post allow-list plus the SVG shapes a toolbar
677 + * icon needs (filterable via mxchat_chat_toolbar_items_allowed_html).
678 + * Add-ons must not add the dropdowns / panels their button opens here —
679 + * the toolbar sits inside an overflow-hidden widget; append those to body
680 + * from JS on first use, the way the Woo cart does.
681 + *
682 + * @param string $bot_id
683 + * @return string HTML, empty when no add-on contributed anything
684 + */
685 + public static function render_toolbar_items($bot_id) {
686 + $items = apply_filters('mxchat_chat_toolbar_items', array(), $bot_id);
687 + if (!is_array($items) || empty($items)) {
688 + return '';
689 + }
690 +
691 + $clean = array();
692 + $seq = 0;
693 + foreach ($items as $key => $item) {
694 + if (!is_array($item) || empty($item['html']) || !is_string($item['html'])) {
695 + continue;
696 + }
697 + $id = isset($item['id']) ? sanitize_key($item['id']) : (is_string($key) ? sanitize_key($key) : '');
698 + if ($id === '') {
699 + continue;
700 + }
701 + $clean[$id] = array(
702 + 'html' => $item['html'],
703 + 'order' => isset($item['order']) ? (int) $item['order'] : 10,
704 + 'seq' => $seq++,
705 + );
706 + }
707 + if (empty($clean)) {
708 + return '';
709 + }
710 +
711 + uasort($clean, function ($a, $b) {
712 + if ($a['order'] === $b['order']) {
713 + return $a['seq'] <=> $b['seq'];
714 + }
715 + return $a['order'] <=> $b['order'];
716 + });
717 +
718 + $allowed = self::toolbar_items_allowed_html();
719 + $out = '';
720 + foreach ($clean as $id => $item) {
721 + $html = trim(wp_kses($item['html'], $allowed));
722 + if ($html === '') {
723 + continue;
724 + }
725 + $out .= ' <!-- toolbar item: ' . esc_html($id) . ' -->' . "\n";
726 + $out .= ' ' . $html . "\n";
727 + }
728 + return $out;
729 + }
730 +
731 + /**
732 + * Allow-list for toolbar item markup: everything wp_kses_post allows (button,
733 + * span, div, aria-* and data-* are in there) plus inline SVG icon shapes.
734 + */
735 + private static function toolbar_items_allowed_html() {
736 + $allowed = wp_kses_allowed_html('post');
737 + $shape = array(
738 + 'fill' => true,
739 + 'stroke' => true,
740 + 'stroke-width' => true,
741 + 'stroke-linecap' => true,
742 + 'stroke-linejoin' => true,
743 + 'transform' => true,
744 + 'style' => true,
745 + 'class' => true,
746 + );
747 + $allowed['svg'] = array_merge($shape, array(
748 + 'xmlns' => true,
749 + 'viewbox' => true,
750 + 'width' => true,
751 + 'height' => true,
752 + 'aria-hidden' => true,
753 + 'focusable' => true,
754 + 'role' => true,
755 + ));
756 + $allowed['path'] = array_merge($shape, array('d' => true));
757 + $allowed['circle'] = array_merge($shape, array('cx' => true, 'cy' => true, 'r' => true));
758 + $allowed['line'] = array_merge($shape, array('x1' => true, 'y1' => true, 'x2' => true, 'y2' => true));
759 + $allowed['polyline'] = array_merge($shape, array('points' => true));
760 + $allowed['polygon'] = array_merge($shape, array('points' => true));
761 + $allowed['rect'] = array_merge($shape, array('x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true));
762 + $allowed['g'] = $shape;
763 + if (!isset($allowed['button'])) {
764 + $allowed['button'] = array();
765 + }
766 + $allowed['button'] = array_merge($allowed['button'], array(
767 + 'type' => true,
768 + 'disabled' => true,
769 + 'aria-haspopup' => true,
770 + 'aria-pressed' => true,
771 + ));
772 + return apply_filters('mxchat_chat_toolbar_items_allowed_html', $allowed);
773 + }
774 +
775 + /**
551 776 * Get bot-specific options for multi-bot functionality
552 - * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
777 +* Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
553 778 */
554 779 private function get_bot_options($bot_id = 'default') {
555 780 // If default bot or multi-bot add-on not active, return empty (use default options)
556 781 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
@@ -586,13 +811,27 @@
586 811 }
587 812
588 813
589 814 private function determine_email_collection_state() {
590 - // Logged-in users skip the email form
815 + // Logged-in users skip the email form, UNLESS the owner has turned on
816 + // "Also Show for Logged-In Users" (c0cfaf). On a membership or LMS site
817 + // every visitor is signed in, so the short-circuit meant lead capture —
818 + // and the consent checkbox — never appeared at all.
819 + //
820 + // Deliberately no PII in this decision: the returned profile values are
821 + // NOT rendered into the markup, because this page HTML is cacheable and
822 + // a cached form carrying one user's address would serve it to the next
823 + // visitor. Pre-fill travels on the nocache'd AJAX check instead
824 + // (mxchat_check_email_provided). Both logged-in and guest requests render
825 + // the same empty form, so the cached copy is correct for either.
591 826 if (is_user_logged_in()) {
827 + $options = get_option('mxchat_options', array());
828 + $show_for_logged_in = isset($options['lead_capture_logged_in_toggle'])
829 + && $options['lead_capture_logged_in_toggle'] === 'on';
830 +
592 831 $current_user = wp_get_current_user();
593 832 return [
594 - 'show_email_form' => false,
833 + 'show_email_form' => $show_for_logged_in,
595 834 'user_email' => $current_user->user_email,
596 835 'user_name' => $current_user->display_name ?: $current_user->first_name ?: ''
597 836 ];
598 837 }