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 +244 -11 3.2.21 → 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
@@ -500,14 +605,22 @@
500 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;"/>';
501 606 echo ' <circle cx="12" cy="13" r="3" style="fill: none !important;"/>';
502 607 echo ' </svg>';
503 608 echo ' </button>';
504 -
505 -
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 +
506 619 echo ' </div>';
507 -
620 +
508 621 echo ' <div class="chatbot-footer">';
509 -
622 +
510 623 // Output the privacy notice if enabled
511 624 if ($privacy_toggle && !empty($privacy_text)) {
512 625 echo '<p class="privacy-notice">' . $privacy_text . '</p>';
513 626 }
@@ -553,10 +666,116 @@
553 666 return ob_get_clean();
554 667 }
555 668
556 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 + /**
557 776 * Get bot-specific options for multi-bot functionality
558 - * 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
559 778 */
560 779 private function get_bot_options($bot_id = 'default') {
561 780 // If default bot or multi-bot add-on not active, return empty (use default options)
562 781 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
@@ -592,13 +811,27 @@
592 811 }
593 812
594 813
595 814 private function determine_email_collection_state() {
596 - // 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.
597 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 +
598 831 $current_user = wp_get_current_user();
599 832 return [
600 - 'show_email_form' => false,
833 + 'show_email_form' => $show_for_logged_in,
601 834 'user_email' => $current_user->user_email,
602 835 'user_name' => $current_user->display_name ?: $current_user->first_name ?: ''
603 836 ];
604 837 }