PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
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 | js/chat-script.js +346 -121 3.2.21 → 3.2.24 View file →
@@ -167,8 +167,16 @@
167 167 // Instance registry - tracks all chatbot instances on the page
168 168 const MxChatInstances = {
169 169 instances: {},
170 170
171 + // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172 + // events below fire once; a script that loads late checks these instead.
173 + toolbarReady: false,
174 + opened: {},
175 + hasOpened: function(botId) {
176 + return !!this.opened[botId || 'default'];
177 + },
178 +
171 179 // Initialize an instance for a bot
172 180 init: function(botId) {
173 181 if (!this.instances[botId]) {
174 182 // When persistence is OFF, track when this session started
@@ -317,8 +325,50 @@
317 325 }
318 326 };
319 327
320 328 // ====================================
329 + // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6)
330 + // ====================================
331 + //
332 + // Document-level CustomEvents an add-on listens for instead of polling the
333 + // DOM or firing requests on page load:
334 + // mxchat:toolbar-ready detail { enabled } once, right after the
335 + // toolbar show/hide pass; items rendered through the
336 + // mxchat_chat_toolbar_items filter can bind now.
337 + // mxchat:opened detail { botId, embedded } once per bot per page
338 + // load, the first time its window is shown — the
339 + // launcher, the pre-chat bubble, an embedded widget
340 + // (at load), or any script that reveals it.
341 + // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId).
342 + function mxchatDispatch(name, detail) {
343 + try {
344 + document.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
345 + } catch (err) { /* CustomEvent unsupported — nothing to notify */ }
346 + }
347 +
348 + function mxchatFireOpened(botId) {
349 + botId = botId || 'default';
350 + if (MxChatInstances.opened[botId]) return;
351 + MxChatInstances.opened[botId] = true;
352 + mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) });
353 + }
354 +
355 + // Catch openers this file does not own (the Trigger add-on, theme buttons,
356 + // custom scripts): a floating window gaining .visible counts as opened.
357 + function mxchatWatchForOpen(botId) {
358 + var el = getElementDOM(botId, 'floating-chatbot');
359 + if (!el || typeof MutationObserver === 'undefined') return;
360 + if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; }
361 + var obs = new MutationObserver(function() {
362 + if (el.classList.contains('visible')) {
363 + mxchatFireOpened(botId);
364 + obs.disconnect();
365 + }
366 + });
367 + obs.observe(el, { attributes: true, attributeFilter: ['class'] });
368 + }
369 +
370 + // ====================================
321 371 // ELEMENT SELECTOR HELPERS
322 372 // ====================================
323 373
324 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -471,23 +521,28 @@
471 521 // ====================================
472 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
473 523 // ====================================
474 524
525 +// Plan f3e515: the page context is computed once per page, not once per message.
526 +// Keyed on URL + title + the content element's text length — cheap to check,
527 +// and enough to notice a navigation or a re-rendered content area.
528 +var pageContextCache = { key: null, value: null };
529 +
475 530 function getPageContext() {
476 531 // Check if contextual awareness is enabled
477 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
478 533 return null;
479 534 }
480 -
535 +
481 536 // Get page URL
482 537 const pageUrl = window.location.href;
483 -
538 +
484 539 // Get page title
485 540 const pageTitle = document.title || '';
486 -
541 +
487 542 // Get main content from the page
488 543 let pageContent = '';
489 -
544 +
490 545 // Try to get content from common content areas
491 546 const contentSelectors = [
492 547 'main',
493 548 '[role="main"]',
@@ -499,9 +554,9 @@
499 554 'article',
500 555 '#content',
501 556 '#main'
502 557 ];
503 -
558 +
504 559 let contentElement = null;
505 560 for (const selector of contentSelectors) {
506 561 contentElement = document.querySelector(selector);
507 562 if (contentElement) {
@@ -507,18 +562,31 @@
507 562 if (contentElement) {
508 563 break;
509 564 }
510 565 }
511 -
566 +
512 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
513 568 if (!contentElement) {
514 569 contentElement = document.body;
515 570 }
516 -
571 +
517 572 if (contentElement) {
518 - // Clone the element to avoid modifying the original
519 - const clone = contentElement.cloneNode(true);
520 -
573 + const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length;
574 + if (pageContextCache.key === cacheKey) {
575 + return pageContextCache.value;
576 + }
577 +
578 + // Copy the element into a document that has no browsing context
579 + // (plan f3e515). A live cloneNode(true) constructed real <video> /
580 + // <audio> / <img> elements: media with a src runs resource selection
581 + // on creation whether or not it is attached, and an autoplay attribute
582 + // made the detached copy PLAY — audibly, once the visible player had
583 + // been unmuted through the property. Every message spawned another
584 + // one. Elements in an inert document never fetch or play, while
585 + // querySelectorAll / textContent / dataset all still work on them.
586 + const inertDocument = document.implementation.createHTMLDocument('');
587 + const clone = inertDocument.importNode(contentElement, true);
588 +
521 589 // Remove unwanted elements
522 590 const unwantedSelectors = [
523 591 'header',
524 592 'footer',
@@ -536,16 +604,25 @@
536 604 '#floating-chatbot',
537 605 '#floating-chatbot-button',
538 606 '.mxchat',
539 607 '[class*="chat"]',
540 - '[id*="chat"]'
608 + '[id*="chat"]',
609 + // Media contributes no text and (outside the inert document) fetches.
610 + 'video',
611 + 'audio',
612 + 'iframe',
613 + 'object',
614 + 'embed',
615 + 'picture',
616 + 'img',
617 + 'source'
541 618 ];
542 -
619 +
543 620 unwantedSelectors.forEach(selector => {
544 621 const elements = clone.querySelectorAll(selector);
545 622 elements.forEach(el => el.remove());
546 623 });
547 -
624 +
548 625 // Extract MxChat context data attributes before getting text content
549 626 const contextData = [];
550 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
551 628 const contextValue = el.dataset.mxchatContext;
@@ -552,34 +629,34 @@
552 629 if (contextValue && contextValue.trim()) {
553 630 contextData.push(contextValue);
554 631 }
555 632 });
556 -
633 +
557 634 // Get text content and clean it up
558 635 pageContent = clone.textContent || clone.innerText || '';
559 -
636 +
560 637 // Add context data to page content if any were found
561 638 if (contextData.length > 0) {
562 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
563 640 }
564 -
641 +
565 642 // Clean up whitespace and limit length
566 643 pageContent = pageContent
567 644 .replace(/\s+/g, ' ')
568 645 .trim()
569 646 .substring(0, 3000); // Limit to 3000 characters to avoid token limits
647 +
648 + // Only return context if we have meaningful content
649 + const result = (!pageContent || pageContent.length < 50) ? null : {
650 + url: pageUrl,
651 + title: pageTitle,
652 + content: pageContent
653 + };
654 + pageContextCache = { key: cacheKey, value: result };
655 + return result;
570 656 }
571 -
572 - // Only return context if we have meaningful content
573 - if (!pageContent || pageContent.length < 50) {
574 - return null;
575 - }
576 -
577 - return {
578 - url: pageUrl,
579 - title: pageTitle,
580 - content: pageContent
581 - };
657 +
658 + return null;
582 659 }
583 660
584 661 // Track originating page when chat starts
585 662 function trackOriginatingPage() {
@@ -770,9 +847,12 @@
770 847 if (typeof customMxChatFilter === 'function') {
771 848 message = customMxChatFilter(message, "prompt");
772 849 }
773 850
774 - if (message) {
851 + // Whitespace-only is empty: nothing is sent and nothing is disabled. When
852 + // there IS a message the original string (spaces and all) is what goes out,
853 + // so the transcript is unchanged (plan b67fd5).
854 + if (message && String(message).trim() !== '') {
775 855 // Don't disable input in live agent mode - let users chat freely
776 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
777 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
778 858 if (!isAgentMode) {
@@ -816,8 +896,14 @@
816 896 if (typeof customMxChatFilter === 'function') {
817 897 message = customMxChatFilter(message, "prompt");
818 898 }
819 899
900 + // Nothing to send means nothing to disable (plan b67fd5): a caller handing
901 + // over an empty string must not lock the box the way Enter-on-empty did.
902 + if (!message || String(message).trim() === '') {
903 + return;
904 + }
905 +
820 906 // Don't disable input in live agent mode - let users chat freely
821 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
822 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
823 909 if (!isAgentMode) {
@@ -1220,11 +1306,26 @@
1220 1306 let testingDataReceived = false;
1221 1307 let streamingStarted = false;
1222 1308 // Server-pushed html to append as its OWN bot bubble once the stream
1223 1309 // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1224 - // deferred to [DONE] so the embed always lands BELOW the streamed text.
1310 + // deferred to the end of the stream so the embed always lands BELOW the
1311 + // streamed text.
1225 1312 let pendingAppendHtml = '';
1226 1313
1314 + // Render the stashed appendix as its own bot bubble below the streamed
1315 + // text — mirrors how it is saved in the transcript, so history replays
1316 + // identically. Called from BOTH ends of a stream: the [DONE] line and
1317 + // the stream simply closing (the OpenAI Responses path and Gemini end
1318 + // without a [DONE] line). The stash is cleared, so a stream that sends
1319 + // [DONE] and then closes renders it once. Any future appendix-type
1320 + // event belongs in here, not in one of the two branches.
1321 + function flushAppendHtml() {
1322 + if (pendingAppendHtml) {
1323 + appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1324 + pendingAppendHtml = '';
1325 + }
1326 + }
1327 +
1227 1328 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1228 1329 // The Stop control (send button swapped in place) aborts both the read
1229 1330 // loop and the underlying request.
1230 1331 var streamControl = { controller: new AbortController(), aborted: false };
@@ -1305,8 +1406,10 @@
1305 1406
1306 1407 // Re-enable chat input when stream ends with content
1307 1408 enableChatInput(botId);
1308 1409
1410 + flushAppendHtml();
1411 +
1309 1412 // Scroll the user's last message to the top now that the
1310 1413 // bot's full reply has rendered (gives max reading room).
1311 1414 var $chatBoxDone = getElement(botId, 'chat-box');
1312 1415 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
@@ -1337,16 +1440,9 @@
1337 1440
1338 1441 // Re-enable chat input after streaming completes
1339 1442 enableChatInput(botId);
1340 1443
1341 - // Render any server-pushed appendix html (e.g. the
1342 - // YouTube embed) as its own bot bubble below the
1343 - // streamed text — mirrors how it is saved in the
1344 - // transcript, so history replays identically.
1345 - if (pendingAppendHtml) {
1346 - appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1347 - pendingAppendHtml = '';
1348 - }
1444 + flushAppendHtml();
1349 1445
1350 1446 // Scroll the user's last message to the top now
1351 1447 // that the bot's full reply has rendered.
1352 1448 var $chatBoxStreamDone = getElement(botId, 'chat-box');
@@ -1676,25 +1772,21 @@
1676 1772 if (this.__mxchatStopJustShown) return;
1677 1773 mxchatStopStreaming(botId);
1678 1774 return;
1679 1775 }
1680 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1681 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1682 - disableChatInput(botId);
1683 - }
1776 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1684 1777 sendMessage(botId);
1685 1778 });
1686 1779
1687 -// Override enter key handler (using event delegation)
1780 +// Enter sends, Shift+Enter inserts a newline (using event delegation).
1781 +// sendMessage() owns the disable: it greys the box only when a real message is
1782 +// about to go out. Disabling here first left the widget locked for good after
1783 +// Enter on an empty box - the send returned early and no reply path ever
1784 +// re-enabled it (plan b67fd5).
1688 1785 $(document).on('keypress', '.chat-input', function(e) {
1689 1786 if (e.which == 13 && !e.shiftKey) {
1690 1787 e.preventDefault();
1691 - var botId = getBotIdFromElement(this);
1692 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1693 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1694 - disableChatInput(botId);
1695 - }
1696 - sendMessage(botId);
1788 + sendMessage(getBotIdFromElement(this));
1697 1789 }
1698 1790 });
1699 1791
1700 1792 // Chat input character counter + soft limit feedback (plan 7091a2).
@@ -2169,8 +2261,56 @@
2169 2261 // Error rendering message - silently continue
2170 2262 }
2171 2263 }
2172 2264
2265 +// Link clicks in bot and agent messages: the click is recorded, the browser
2266 +// follows the anchor itself. Navigation must not wait for the tracking
2267 +// request: a window.open() made from that request's callback is outside the
2268 +// visitor's tap, and Safari's pop-up blocker drops it without a message.
2269 +
2270 +// Give a tracked anchor the target it should open in, and noopener on a new tab.
2271 +function prepareTrackedLink($link) {
2272 + if (linkTarget === '_blank' && $link.attr('target') !== '_blank') {
2273 + $link.attr('target', '_blank');
2274 + }
2275 + if ($link.attr('target') === '_blank') {
2276 + var rel = $link.attr('rel') || '';
2277 + if (!/(^|\s)noopener(\s|$)/.test(rel)) {
2278 + $link.attr('rel', $.trim(rel + ' noopener'));
2279 + }
2280 + }
2281 +}
2282 +
2283 +// Record one click, fire-and-forget. sendBeacon survives the page unloading
2284 +// when the link opens in the same tab.
2285 +function trackLinkClick(href, messageContext, botId) {
2286 + var fields = {
2287 + action: 'mxchat_track_url_click',
2288 + session_id: getChatSession(botId),
2289 + url: href,
2290 + message_context: messageContext,
2291 + nonce: mxchatChat.nonce
2292 + };
2293 + try {
2294 + if (navigator.sendBeacon && typeof FormData !== 'undefined') {
2295 + var body = new FormData();
2296 + $.each(fields, function(name, value) {
2297 + body.append(name, value == null ? '' : value);
2298 + });
2299 + if (navigator.sendBeacon(mxchatChat.ajax_url, body)) {
2300 + return;
2301 + }
2302 + }
2303 + } catch (error) {
2304 + // Fall through to the plain request
2305 + }
2306 + $.ajax({
2307 + url: mxchatChat.ajax_url,
2308 + type: 'POST',
2309 + data: fields
2310 + });
2311 +}
2312 +
2173 2313 // Helper function to attach link tracking with proper event handling
2174 2314 function attachLinkTracking(messageDiv, messageText, botId) {
2175 2315 botId = botId || 'default';
2176 2316 // Use a slight delay to ensure DOM is ready
@@ -2188,11 +2328,13 @@
2188 2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
2189 2329 // Remove any existing click handlers first
2190 2330 $link.off('click.tracking');
2191 2331
2332 + // The anchor carries its own target: the browser follows it
2333 + prepareTrackedLink($link);
2334 +
2192 2335 // Add new click handler with namespace
2193 2336 $link.on('click.tracking', function(e) {
2194 - e.preventDefault();
2195 2337 e.stopPropagation();
2196 2338
2197 2339 const messageContext = typeof messageText === 'string'
2198 2340 ? messageText.substring(0, 200)
@@ -2197,30 +2339,10 @@
2197 2339 const messageContext = typeof messageText === 'string'
2198 2340 ? messageText.substring(0, 200)
2199 2341 : '';
2200 2342
2201 - // Track the click
2202 - $.ajax({
2203 - url: mxchatChat.ajax_url,
2204 - type: 'POST',
2205 - data: {
2206 - action: 'mxchat_track_url_click',
2207 - session_id: getChatSession(botId),
2208 - url: originalHref,
2209 - message_context: messageContext,
2210 - nonce: mxchatChat.nonce
2211 - },
2212 - complete: function() {
2213 - // Always redirect, even if tracking fails
2214 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
2215 - window.open(originalHref, '_blank');
2216 - } else {
2217 - window.location.href = originalHref;
2218 - }
2219 - }
2220 - });
2221 -
2222 - return false; // Extra insurance to prevent default
2343 + prepareTrackedLink($link);
2344 + trackLinkClick(originalHref, messageContext, botId);
2223 2345 });
2224 2346 }
2225 2347 });
2226 2348 }, 100); // Small delay to ensure DOM is ready
@@ -2260,13 +2382,16 @@
2260 2382
2261 2383 if (images.length > 0) {
2262 2384 fullMessage += '<div class="image-gallery" dir="auto">';
2263 2385 images.forEach(img => {
2386 + const safeTitle = sanitizeUserInput(img.title);
2387 + const safeUrl = escapeAttr(img.image_url);
2388 + const safeThumbnail = escapeAttr(img.thumbnail_url);
2264 2389 fullMessage += `
2265 2390 <div style="margin-bottom: 10px;">
2266 - <strong>${img.title}</strong><br>
2267 - <a href="${img.image_url}" target="_blank">
2268 - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
2391 + <strong>${safeTitle}</strong><br>
2392 + <a href="${safeUrl}" target="_blank">
2393 + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2269 2394 </a>
2270 2395 </div>`;
2271 2396 });
2272 2397 fullMessage += '</div>';
@@ -2385,27 +2510,42 @@
2385 2510 // Check for % followed by exactly 2 hex digits
2386 2511 return /%[0-9a-fA-F]{2}/.test(url);
2387 2512 }
2388 2513
2389 - // Helper function to safely encode URLs only if needed
2514 + // Helper function to safely encode URLs only if needed, then make the
2515 + // result attribute-safe. "Don't double-encode an already-encoded URL"
2516 + // used to mean "return it untouched" — so a literal quote riding next
2517 + // to a %20 reached href="…" intact and closed the attribute
2518 + // (plan 2db182). Every href this function feeds now goes through
2519 + // escapeAttr(), whether or not the URL was already percent-encoded.
2390 2520 function safeEncodeUrl(url) {
2391 - // If URL already contains encoded characters, return as-is
2392 - if (isUrlEncoded(url)) {
2393 - return url;
2394 - }
2395 - // Otherwise, encode it
2396 - return encodeURI(url);
2521 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 + return escapeAttr(encoded);
2397 2523 }
2398 2524
2399 - // Process markdown headers FIRST
2400 - let processedText = formatMarkdownHeaders(inputText);
2401 -
2525 + // Take backslash escapes and code (fenced + inline) out of the text FIRST
2526 + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2527 + // backtick could not protect an underscore and \_ was not an escape at
2528 + // all; the header rule also ran over fenced code. Each escape becomes a
2529 + // placeholder carrying its literal character, each code span a
2530 + // placeholder carrying its rendered HTML; both are restored below, after
2531 + // the styling rules have run, so nothing downstream changes.
2532 + const literals = protectLiterals(inputText);
2533 + let processedText = literals.text;
2534 +
2535 + // Process markdown headers
2536 + processedText = formatMarkdownHeaders(processedText);
2537 +
2402 2538 // Process text styling (bold, italic, strikethrough)
2403 2539 processedText = formatTextStyling(processedText);
2404 -
2405 - // Process code blocks BEFORE processing links
2540 +
2541 + // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2542 + // rendered inside protectLiterals) — still BEFORE processing links
2406 2543 processedText = formatCodeBlocks(processedText);
2407 2544
2545 + // Put the rendered code and the escaped characters back
2546 + processedText = literals.restore(processedText);
2547 +
2408 2548 // Process markdown tables BEFORE converting newlines to paragraphs
2409 2549 processedText = formatMarkdownTables(processedText);
2410 2550
2411 2551 // NOW convert to paragraphs
@@ -2417,10 +2557,13 @@
2417 2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2418 2558 // Clean the URL of any trailing punctuation
2419 2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2420 2560 const safeUrl = safeEncodeUrl(cleanUrl);
2561 + // The bracket body is the only link text that can carry angle
2562 + // brackets (the standalone matcher excludes them) — keep it text.
2563 + const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
2421 2564 // Return as a proper link without the brackets
2422 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2565 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2423 2566 });
2424 2567
2425 2568 // Process markdown links: [text](url) and [](url)
2426 2569 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2524,8 +2667,30 @@
2524 2667
2525 2668 return processedText;
2526 2669 }
2527 2670
2671 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2672 + // A single alternation keeps the two honest with each other: an escaped
2673 + // backtick is consumed as an escape before it can open a code span, and a
2674 + // code span is consumed whole so the escapes inside it stay verbatim.
2675 + // Placeholders are private-use characters — no underscore, asterisk,
2676 + // backtick, hash, pipe or bracket for a later rule to match.
2677 + function protectLiterals(text) {
2678 + const store = [];
2679 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2680 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2681 + return '\uE000' + (store.length - 1) + '\uE001';
2682 + });
2683 + return {
2684 + text: out,
2685 + restore: function(t) {
2686 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2687 + return store[Number(i)];
2688 + });
2689 + }
2690 + };
2691 + }
2692 +
2528 2693 function formatMarkdownHeaders(text) {
2529 2694 // Handle h1 to h6 headers
2530 2695 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2531 2696 const level = hashes.length;
@@ -2567,11 +2732,24 @@
2567 2732 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2568 2733 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2569 2734 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2570 2735
2571 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2572 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2573 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2736 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2737 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2738 + // text or a non-word character, the closer must precede the end or a
2739 + // non-word character, and the content may not start or end with a
2740 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2741 + // while "use _this_ word" still italicises. A token whose content is
2742 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2743 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2744 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2745 + // adjacent to another underscore or a word character.
2746 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2747 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2748 + return match;
2749 + }
2750 + return before + '<em>' + content + '</em>';
2751 + });
2574 2752
2575 2753 // Handle strikethrough (~~text~~)
2576 2754 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2577 2755
@@ -2705,8 +2883,24 @@
2705 2883 div.textContent = text;
2706 2884 return div.innerHTML;
2707 2885 }
2708 2886
2887 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2888 + // Percent-encodes the characters that can end or extend an attribute.
2889 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2890 + // never changes where a legitimate link resolves — encodeURI() already
2891 + // produces most of these, this just guarantees them for URLs that skip
2892 + // encodeURI() because they were percent-encoded on arrival.
2893 + function escapeAttr(url) {
2894 + return String(url)
2895 + .replace(/"/g, '%22')
2896 + .replace(/'/g, '%27')
2897 + .replace(/</g, '%3C')
2898 + .replace(/>/g, '%3E')
2899 + .replace(/`/g, '%60')
2900 + .replace(/ /g, '%20');
2901 + }
2902 +
2709 2903 function escapeHtml(unsafe) {
2710 2904 // Skip escaping if it's already escaped or contains HTML code block markup
2711 2905 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2712 2906 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -3110,8 +3304,17 @@
3110 3304 var content = message.content;
3111 3305 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3112 3306 content = decodeHTMLEntities(content);
3113 3307
3308 + // A visitor's own rows get the same treatment as the
3309 + // live turn (appendMessage escapes user text before
3310 + // linkify). Without this a replayed user message came
3311 + // back as live markup after a refresh (plan 2db182).
3312 + // Bot/agent rows are trusted HTML and stay as they were.
3313 + if (message.role === 'user') {
3314 + content = sanitizeUserInput(content);
3315 + }
3316 +
3114 3317 // Skip linkify for messages containing structured HTML
3115 3318 // (forms, product cards, galleries, etc.) to avoid
3116 3319 // markdown formatting corrupting HTML attributes
3117 3320 // (e.g. underscores in name="field_name" becoming <em> tags).
@@ -3494,8 +3697,9 @@
3494 3697 $(this).addClass('hidden').attr('aria-expanded', 'true');
3495 3698 $badge.hide(); // Hide notification when opening chat
3496 3699 disableScroll();
3497 3700 $preChat.fadeOut(250);
3701 + mxchatFireOpened(botId);
3498 3702
3499 3703 // First open per page load: re-fetch behavior settings in case
3500 3704 // this page's inline values came from a stale full-page cache
3501 3705 // (plan-32db95). Idempotent — later opens are a no-op.
@@ -3764,9 +3968,12 @@
3764 3968 $('.chat-toolbar').show();
3765 3969 } else {
3766 3970 $('.chat-toolbar').hide();
3767 3971 }
3768 -
3972 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3973 + MxChatInstances.toolbarReady = true;
3974 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3975 +
3769 3976 // Apply toolbar icon colors
3770 3977 const toolbarElements = [
3771 3978 '#mxchat-chatbot .toolbar-btn svg',
3772 3979 '#mxchat-chatbot .active-pdf-name',
@@ -4041,14 +4248,25 @@
4041 4248 return response.json();
4042 4249 })
4043 4250 .then((data) => {
4044 4251 if (data.success) {
4045 - if (data.data.logged_in || data.data.email) {
4252 + // A success response means the server has already decided the
4253 + // required info is on file. With email optional (c0cfaf) that
4254 + // info can be a name and no address, so keying on data.email
4255 + // alone re-prompted a name-only visitor on every page load.
4256 + if (data.data.logged_in || data.data.email || data.data.name) {
4046 4257 showChatContainerForBot(botId);
4047 4258 } else {
4048 4259 showEmailFormForBot(botId);
4049 4260 }
4050 4261 } else {
4262 + // Pre-fill for a signed-in visitor being shown the form
4263 + // (c0cfaf). It arrives on this nocache'd response rather than
4264 + // in the page HTML, which is cacheable. Never overwrite what
4265 + // the visitor has already typed.
4266 + if (data.data) {
4267 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4268 + }
4051 4269 showEmailFormForBot(botId);
4052 4270 }
4053 4271 })
4054 4272 .catch((error) => {
@@ -4055,8 +4273,19 @@
4055 4273 showEmailFormForBot(botId);
4056 4274 });
4057 4275 }
4058 4276
4277 + function prefillEmailForm(botId, name, email) {
4278 + var nameInput = getElementDOM(botId, 'user-name');
4279 + var emailInput = getElementDOM(botId, 'user-email');
4280 + if (nameInput && name && !nameInput.value) {
4281 + nameInput.value = name;
4282 + }
4283 + if (emailInput && email && !emailInput.value) {
4284 + emailInput.value = email;
4285 + }
4286 + }
4287 +
4059 4288 // Event delegation for email form submission
4060 4289 $(document).on('submit', '.email-collection-form', function(e) {
4061 4290 e.preventDefault();
4062 4291 e.stopPropagation();
@@ -4074,19 +4303,29 @@
4074 4303 var userEmail = emailInput ? emailInput.value.trim() : '';
4075 4304 var userName = nameInput ? nameInput.value.trim() : '';
4076 4305 var sessionId = MxChatInstances.ensureSession(botId);
4077 4306
4078 - // Validate email
4079 - if (!userEmail) {
4307 + // Validate email. The address is optional when the owner turned
4308 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4309 + // still has to be a valid address. The server enforces both rules
4310 + // independently.
4311 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4312 +
4313 + if (emailRequired && !userEmail) {
4080 4314 showEmailError(botId, 'Please enter your email address.');
4081 4315 return false;
4082 4316 }
4083 4317
4084 - if (!isValidEmailAddress(userEmail)) {
4318 + if (userEmail && !isValidEmailAddress(userEmail)) {
4085 4319 showEmailError(botId, 'Please enter a valid email address.');
4086 4320 return false;
4087 4321 }
4088 4322
4323 + if (!emailRequired && !userEmail && !userName) {
4324 + showEmailError(botId, 'Please enter your name or email address.');
4325 + return false;
4326 + }
4327 +
4089 4328 // Validate name if field exists and has content
4090 4329 if (nameInput && userName && !isValidNameInput(userName)) {
4091 4330 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4092 4331 return false;
@@ -4242,8 +4481,9 @@
4242 4481 $chatbot.removeClass('hidden').addClass('visible');
4243 4482 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4244 4483 handlePreChatDismissal(botId);
4245 4484 disableScroll(); // Disable scroll when chatbot opens
4485 + mxchatFireOpened(botId);
4246 4486
4247 4487 // Load chat history for returning visitors (persistence)
4248 4488 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4249 4489 if (chatPersistenceEnabled) {
@@ -4350,14 +4590,8 @@
4350 4590 if (messageDiv.length > 0) {
4351 4591 const originalHref = $link.attr('href');
4352 4592
4353 4593 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
4354 - e.preventDefault();
4355 - e.stopPropagation();
4356 -
4357 - // Mark as tracked
4358 - $link.attr('data-tracked', 'true');
4359 -
4360 4594 // Get bot ID from the chat box context
4361 4595 var botId = getBotIdFromElement(this);
4362 4596
4363 4597 // Get message context from the message div
@@ -4362,28 +4596,12 @@
4362 4596
4363 4597 // Get message context from the message div
4364 4598 const messageText = messageDiv.text().substring(0, 200);
4365 4599
4366 - $.ajax({
4367 - url: mxchatChat.ajax_url,
4368 - type: 'POST',
4369 - data: {
4370 - action: 'mxchat_track_url_click',
4371 - session_id: getChatSession(botId),
4372 - url: originalHref,
4373 - message_context: messageText,
4374 - nonce: mxchatChat.nonce
4375 - },
4376 - complete: function() {
4377 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
4378 - window.open(originalHref, '_blank');
4379 - } else {
4380 - window.location.href = originalHref;
4381 - }
4382 - }
4383 - });
4384 -
4385 - return false;
4600 + // Same path as attachLinkTracking(): set the target, record the
4601 + // click, and let the browser follow the anchor.
4602 + prepareTrackedLink($link);
4603 + trackLinkClick(originalHref, messageText, botId);
4386 4604 }
4387 4605 }
4388 4606 });
4389 4607
@@ -4409,8 +4627,15 @@
4409 4627 // Initialize chat visibility for all instances
4410 4628 $('.mxchat-chatbot-wrapper').each(function() {
4411 4629 var botId = $(this).data('bot-id') || 'default';
4412 4630 initializeChatVisibility(botId);
4631 + // An embedded widget is on screen from the first paint; a floating one
4632 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4633 + if (isEmbeddedBot(botId)) {
4634 + mxchatFireOpened(botId);
4635 + } else {
4636 + mxchatWatchForOpen(botId);
4637 + }
4413 4638 });
4414 4639
4415 4640 // Make functions globally available for add-ons
4416 4641 window.hasQuickQuestions = hasQuickQuestions;