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 | js/chat-script.js +405 -132 3.2.20 → 3.2.25 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
@@ -284,9 +292,20 @@
284 292 var newSessionId = generateSessionId();
285 293 this.setChatSession(botId, newSessionId);
286 294 var $chatBox = getElement(botId, 'chat-box');
287 295 if ($chatBox.length) {
288 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
296 + // Keep the greeting, drop everything else. Identify the greeting
297 + // by its marker class, NOT by position (plan a1a79b): after a
298 + // chat-persistence restore the first .bot-message is a real
299 + // reply, so ":not(:first)" left a stale answer sitting at the
300 + // top of an otherwise empty box. The positional fallback only
301 + // runs when the marker is absent — a page served from HTML cache
302 + // that predates this release — and behaves exactly as before.
303 + if ($chatBox.find('.mxchat-intro-message').length) {
304 + $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
305 + } else {
306 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
307 + }
289 308 }
290 309 if (this.instances[botId]) {
291 310 this.instances[botId].chatHistoryLoaded = false;
292 311 this.instances[botId].processedMessageIds = new Set();
@@ -306,8 +325,50 @@
306 325 }
307 326 };
308 327
309 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 + // ====================================
310 371 // ELEMENT SELECTOR HELPERS
311 372 // ====================================
312 373
313 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -460,23 +521,28 @@
460 521 // ====================================
461 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
462 523 // ====================================
463 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 +
464 530 function getPageContext() {
465 531 // Check if contextual awareness is enabled
466 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
467 533 return null;
468 534 }
469 -
535 +
470 536 // Get page URL
471 537 const pageUrl = window.location.href;
472 -
538 +
473 539 // Get page title
474 540 const pageTitle = document.title || '';
475 -
541 +
476 542 // Get main content from the page
477 543 let pageContent = '';
478 -
544 +
479 545 // Try to get content from common content areas
480 546 const contentSelectors = [
481 547 'main',
482 548 '[role="main"]',
@@ -488,9 +554,9 @@
488 554 'article',
489 555 '#content',
490 556 '#main'
491 557 ];
492 -
558 +
493 559 let contentElement = null;
494 560 for (const selector of contentSelectors) {
495 561 contentElement = document.querySelector(selector);
496 562 if (contentElement) {
@@ -496,18 +562,31 @@
496 562 if (contentElement) {
497 563 break;
498 564 }
499 565 }
500 -
566 +
501 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
502 568 if (!contentElement) {
503 569 contentElement = document.body;
504 570 }
505 -
571 +
506 572 if (contentElement) {
507 - // Clone the element to avoid modifying the original
508 - const clone = contentElement.cloneNode(true);
509 -
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 +
510 589 // Remove unwanted elements
511 590 const unwantedSelectors = [
512 591 'header',
513 592 'footer',
@@ -525,16 +604,25 @@
525 604 '#floating-chatbot',
526 605 '#floating-chatbot-button',
527 606 '.mxchat',
528 607 '[class*="chat"]',
529 - '[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'
530 618 ];
531 -
619 +
532 620 unwantedSelectors.forEach(selector => {
533 621 const elements = clone.querySelectorAll(selector);
534 622 elements.forEach(el => el.remove());
535 623 });
536 -
624 +
537 625 // Extract MxChat context data attributes before getting text content
538 626 const contextData = [];
539 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
540 628 const contextValue = el.dataset.mxchatContext;
@@ -541,34 +629,34 @@
541 629 if (contextValue && contextValue.trim()) {
542 630 contextData.push(contextValue);
543 631 }
544 632 });
545 -
633 +
546 634 // Get text content and clean it up
547 635 pageContent = clone.textContent || clone.innerText || '';
548 -
636 +
549 637 // Add context data to page content if any were found
550 638 if (contextData.length > 0) {
551 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
552 640 }
553 -
641 +
554 642 // Clean up whitespace and limit length
555 643 pageContent = pageContent
556 644 .replace(/\s+/g, ' ')
557 645 .trim()
558 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;
559 656 }
560 -
561 - // Only return context if we have meaningful content
562 - if (!pageContent || pageContent.length < 50) {
563 - return null;
564 - }
565 -
566 - return {
567 - url: pageUrl,
568 - title: pageTitle,
569 - content: pageContent
570 - };
657 +
658 + return null;
571 659 }
572 660
573 661 // Track originating page when chat starts
574 662 function trackOriginatingPage() {
@@ -759,9 +847,12 @@
759 847 if (typeof customMxChatFilter === 'function') {
760 848 message = customMxChatFilter(message, "prompt");
761 849 }
762 850
763 - 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() !== '') {
764 855 // Don't disable input in live agent mode - let users chat freely
765 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
766 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
767 858 if (!isAgentMode) {
@@ -805,8 +896,14 @@
805 896 if (typeof customMxChatFilter === 'function') {
806 897 message = customMxChatFilter(message, "prompt");
807 898 }
808 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 +
809 906 // Don't disable input in live agent mode - let users chat freely
810 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
811 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
812 909 if (!isAgentMode) {
@@ -1209,11 +1306,26 @@
1209 1306 let testingDataReceived = false;
1210 1307 let streamingStarted = false;
1211 1308 // Server-pushed html to append as its OWN bot bubble once the stream
1212 1309 // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1213 - // 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.
1214 1312 let pendingAppendHtml = '';
1215 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 +
1216 1328 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1217 1329 // The Stop control (send button swapped in place) aborts both the read
1218 1330 // loop and the underlying request.
1219 1331 var streamControl = { controller: new AbortController(), aborted: false };
@@ -1294,8 +1406,10 @@
1294 1406
1295 1407 // Re-enable chat input when stream ends with content
1296 1408 enableChatInput(botId);
1297 1409
1410 + flushAppendHtml();
1411 +
1298 1412 // Scroll the user's last message to the top now that the
1299 1413 // bot's full reply has rendered (gives max reading room).
1300 1414 var $chatBoxDone = getElement(botId, 'chat-box');
1301 1415 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
@@ -1326,16 +1440,9 @@
1326 1440
1327 1441 // Re-enable chat input after streaming completes
1328 1442 enableChatInput(botId);
1329 1443
1330 - // Render any server-pushed appendix html (e.g. the
1331 - // YouTube embed) as its own bot bubble below the
1332 - // streamed text — mirrors how it is saved in the
1333 - // transcript, so history replays identically.
1334 - if (pendingAppendHtml) {
1335 - appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1336 - pendingAppendHtml = '';
1337 - }
1444 + flushAppendHtml();
1338 1445
1339 1446 // Scroll the user's last message to the top now
1340 1447 // that the bot's full reply has rendered.
1341 1448 var $chatBoxStreamDone = getElement(botId, 'chat-box');
@@ -1665,25 +1772,21 @@
1665 1772 if (this.__mxchatStopJustShown) return;
1666 1773 mxchatStopStreaming(botId);
1667 1774 return;
1668 1775 }
1669 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1670 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1671 - disableChatInput(botId);
1672 - }
1776 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1673 1777 sendMessage(botId);
1674 1778 });
1675 1779
1676 -// 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).
1677 1785 $(document).on('keypress', '.chat-input', function(e) {
1678 1786 if (e.which == 13 && !e.shiftKey) {
1679 1787 e.preventDefault();
1680 - var botId = getBotIdFromElement(this);
1681 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1682 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1683 - disableChatInput(botId);
1684 - }
1685 - sendMessage(botId);
1788 + sendMessage(getBotIdFromElement(this));
1686 1789 }
1687 1790 });
1688 1791
1689 1792 // Chat input character counter + soft limit feedback (plan 7091a2).
@@ -2158,8 +2261,56 @@
2158 2261 // Error rendering message - silently continue
2159 2262 }
2160 2263 }
2161 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 +
2162 2313 // Helper function to attach link tracking with proper event handling
2163 2314 function attachLinkTracking(messageDiv, messageText, botId) {
2164 2315 botId = botId || 'default';
2165 2316 // Use a slight delay to ensure DOM is ready
@@ -2177,11 +2328,13 @@
2177 2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
2178 2329 // Remove any existing click handlers first
2179 2330 $link.off('click.tracking');
2180 2331
2332 + // The anchor carries its own target: the browser follows it
2333 + prepareTrackedLink($link);
2334 +
2181 2335 // Add new click handler with namespace
2182 2336 $link.on('click.tracking', function(e) {
2183 - e.preventDefault();
2184 2337 e.stopPropagation();
2185 2338
2186 2339 const messageContext = typeof messageText === 'string'
2187 2340 ? messageText.substring(0, 200)
@@ -2186,30 +2339,10 @@
2186 2339 const messageContext = typeof messageText === 'string'
2187 2340 ? messageText.substring(0, 200)
2188 2341 : '';
2189 2342
2190 - // Track the click
2191 - $.ajax({
2192 - url: mxchatChat.ajax_url,
2193 - type: 'POST',
2194 - data: {
2195 - action: 'mxchat_track_url_click',
2196 - session_id: getChatSession(botId),
2197 - url: originalHref,
2198 - message_context: messageContext,
2199 - nonce: mxchatChat.nonce
2200 - },
2201 - complete: function() {
2202 - // Always redirect, even if tracking fails
2203 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
2204 - window.open(originalHref, '_blank');
2205 - } else {
2206 - window.location.href = originalHref;
2207 - }
2208 - }
2209 - });
2210 -
2211 - return false; // Extra insurance to prevent default
2343 + prepareTrackedLink($link);
2344 + trackLinkClick(originalHref, messageContext, botId);
2212 2345 });
2213 2346 }
2214 2347 });
2215 2348 }, 100); // Small delay to ensure DOM is ready
@@ -2249,13 +2382,16 @@
2249 2382
2250 2383 if (images.length > 0) {
2251 2384 fullMessage += '<div class="image-gallery" dir="auto">';
2252 2385 images.forEach(img => {
2386 + const safeTitle = sanitizeUserInput(img.title);
2387 + const safeUrl = escapeAttr(img.image_url);
2388 + const safeThumbnail = escapeAttr(img.thumbnail_url);
2253 2389 fullMessage += `
2254 2390 <div style="margin-bottom: 10px;">
2255 - <strong>${img.title}</strong><br>
2256 - <a href="${img.image_url}" target="_blank">
2257 - <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;" />
2258 2394 </a>
2259 2395 </div>`;
2260 2396 });
2261 2397 fullMessage += '</div>';
@@ -2374,27 +2510,42 @@
2374 2510 // Check for % followed by exactly 2 hex digits
2375 2511 return /%[0-9a-fA-F]{2}/.test(url);
2376 2512 }
2377 2513
2378 - // 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.
2379 2520 function safeEncodeUrl(url) {
2380 - // If URL already contains encoded characters, return as-is
2381 - if (isUrlEncoded(url)) {
2382 - return url;
2383 - }
2384 - // Otherwise, encode it
2385 - return encodeURI(url);
2521 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 + return escapeAttr(encoded);
2386 2523 }
2387 2524
2388 - // Process markdown headers FIRST
2389 - let processedText = formatMarkdownHeaders(inputText);
2390 -
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 +
2391 2538 // Process text styling (bold, italic, strikethrough)
2392 2539 processedText = formatTextStyling(processedText);
2393 -
2394 - // 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
2395 2543 processedText = formatCodeBlocks(processedText);
2396 2544
2545 + // Put the rendered code and the escaped characters back
2546 + processedText = literals.restore(processedText);
2547 +
2397 2548 // Process markdown tables BEFORE converting newlines to paragraphs
2398 2549 processedText = formatMarkdownTables(processedText);
2399 2550
2400 2551 // NOW convert to paragraphs
@@ -2406,10 +2557,13 @@
2406 2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2407 2558 // Clean the URL of any trailing punctuation
2408 2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2409 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;');
2410 2564 // Return as a proper link without the brackets
2411 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2565 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2412 2566 });
2413 2567
2414 2568 // Process markdown links: [text](url) and [](url)
2415 2569 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2483,9 +2637,17 @@
2483 2637 });
2484 2638
2485 2639 // Process standalone URLs - but NOT if they're already in <a> tags or brackets
2486 2640 // Updated pattern to be more careful about what it matches
2487 - const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2641 + //
2642 + // The prefix group (plan 78618d) is the start of a line, an ordinary
2643 + // character, OR a tag this formatter itself opened or closed (paragraph,
2644 + // list item, table cell, heading, emphasis, line break), so an address
2645 + // that opens a paragraph or sits inside **bold** is linked too. Never
2646 + // an anchor (its text would be linked twice; the lookahead guards that
2647 + // as well) and never a code span, which stays as typed. No lookbehind,
2648 + // for Safari.
2649 + const urlPattern = /(^|[^">=\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2488 2650 processedText = processedText.replace(urlPattern, (match, prefix, url) => {
2489 2651 // Extra check: make sure this isn't already linked
2490 2652 if (match.includes('href=') || match.includes('</a>')) {
2491 2653 return match;
@@ -2496,10 +2658,14 @@
2496 2658 const safeUrl = safeEncodeUrl(cleanUrl);
2497 2659 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2498 2660 });
2499 2661
2500 - // Process www. URLs - but NOT if they're already in <a> tags or brackets
2501 - const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim;
2662 + // Process www. URLs - but NOT if they're already in <a> tags or brackets.
2663 + // The address stops at the same characters the https pattern stops at
2664 + // (plan 78618d): it used to run through the closing paragraph tag, so
2665 + // "Try www.example.com/page." linked to www.example.com/page.%3C/p%3E.
2666 + // Same prefix group as the https pattern, same reasons.
2667 + const wwwPattern = /(^|[^">/\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(www\.[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2502 2668 processedText = processedText.replace(wwwPattern, (match, prefix, url) => {
2503 2669 // Extra check: make sure this isn't already linked
2504 2670 if (match.includes('href=') || match.includes('</a>')) {
2505 2671 return match;
@@ -2513,8 +2679,30 @@
2513 2679
2514 2680 return processedText;
2515 2681 }
2516 2682
2683 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2684 + // A single alternation keeps the two honest with each other: an escaped
2685 + // backtick is consumed as an escape before it can open a code span, and a
2686 + // code span is consumed whole so the escapes inside it stay verbatim.
2687 + // Placeholders are private-use characters — no underscore, asterisk,
2688 + // backtick, hash, pipe or bracket for a later rule to match.
2689 + function protectLiterals(text) {
2690 + const store = [];
2691 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2692 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2693 + return '\uE000' + (store.length - 1) + '\uE001';
2694 + });
2695 + return {
2696 + text: out,
2697 + restore: function(t) {
2698 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2699 + return store[Number(i)];
2700 + });
2701 + }
2702 + };
2703 + }
2704 +
2517 2705 function formatMarkdownHeaders(text) {
2518 2706 // Handle h1 to h6 headers
2519 2707 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2520 2708 const level = hashes.length;
@@ -2541,13 +2729,19 @@
2541 2729 protectedSegments.push(match);
2542 2730 return placeholder;
2543 2731 });
2544 2732
2545 - // Step 1c: Also protect bare URLs that might exist
2733 + // Step 1c: Also protect bare URLs that might exist. Trailing emphasis
2734 + // markers are not part of the address (plan 78618d): **https://x.com**
2735 + // used to protect "https://x.com**", so the bold never paired and the
2736 + // asterisks ended up inside the link. The markers stay outside the
2737 + // placeholder so the styling rules below can pair them.
2546 2738 protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) {
2739 + const markers = match.match(/[*_~]+$/);
2740 + const url = markers ? match.slice(0, match.length - markers[0].length) : match;
2547 2741 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2548 - protectedSegments.push(match);
2549 - return placeholder;
2742 + protectedSegments.push(url);
2743 + return placeholder + (markers ? markers[0] : '');
2550 2744 });
2551 2745
2552 2746 // Step 2: Now apply text styling to the protected text
2553 2747 // Handle bold text (**text**)
@@ -2556,11 +2750,24 @@
2556 2750 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2557 2751 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2558 2752 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2559 2753
2560 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2561 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2562 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2754 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2755 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2756 + // text or a non-word character, the closer must precede the end or a
2757 + // non-word character, and the content may not start or end with a
2758 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2759 + // while "use _this_ word" still italicises. A token whose content is
2760 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2761 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2762 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2763 + // adjacent to another underscore or a word character.
2764 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2765 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2766 + return match;
2767 + }
2768 + return before + '<em>' + content + '</em>';
2769 + });
2563 2770
2564 2771 // Handle strikethrough (~~text~~)
2565 2772 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2566 2773
@@ -2694,8 +2901,24 @@
2694 2901 div.textContent = text;
2695 2902 return div.innerHTML;
2696 2903 }
2697 2904
2905 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2906 + // Percent-encodes the characters that can end or extend an attribute.
2907 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2908 + // never changes where a legitimate link resolves — encodeURI() already
2909 + // produces most of these, this just guarantees them for URLs that skip
2910 + // encodeURI() because they were percent-encoded on arrival.
2911 + function escapeAttr(url) {
2912 + return String(url)
2913 + .replace(/"/g, '%22')
2914 + .replace(/'/g, '%27')
2915 + .replace(/</g, '%3C')
2916 + .replace(/>/g, '%3E')
2917 + .replace(/`/g, '%60')
2918 + .replace(/ /g, '%20');
2919 + }
2920 +
2698 2921 function escapeHtml(unsafe) {
2699 2922 // Skip escaping if it's already escaped or contains HTML code block markup
2700 2923 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2701 2924 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -3051,9 +3274,18 @@
3051 3274 if (mxLiveRegionEl) {
3052 3275 mxLiveRegionEl.setAttribute('aria-live', 'off');
3053 3276 }
3054 3277
3055 - // IMPORTANT: Clear existing messages before loading history
3278 + // IMPORTANT: Clear existing messages before loading history.
3279 + // Detach the greeting first and put it back below —
3280 + // it is server-rendered and never stored in the
3281 + // transcript, so the old unconditional .empty()
3282 + // deleted it for the rest of the page life (plan
3283 + // a1a79b). Detach rather than rebuild: intro_message
3284 + // is not localized to JS, and this node already
3285 + // carries the per-bot inline colors and any
3286 + // {visitor_name} substitution already applied to it.
3287 + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
3056 3288 $chatBox.empty();
3057 3289
3058 3290 $.each(response.data.conversation, function(index, message) {
3059 3291 // Skip agent messages if persistence is off
@@ -3090,8 +3322,17 @@
3090 3322 var content = message.content;
3091 3323 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3092 3324 content = decodeHTMLEntities(content);
3093 3325
3326 + // A visitor's own rows get the same treatment as the
3327 + // live turn (appendMessage escapes user text before
3328 + // linkify). Without this a replayed user message came
3329 + // back as live markup after a refresh (plan 2db182).
3330 + // Bot/agent rows are trusted HTML and stay as they were.
3331 + if (message.role === 'user') {
3332 + content = sanitizeUserInput(content);
3333 + }
3334 +
3094 3335 // Skip linkify for messages containing structured HTML
3095 3336 // (forms, product cards, galleries, etc.) to avoid
3096 3337 // markdown formatting corrupting HTML attributes
3097 3338 // (e.g. underscores in name="field_name" becoming <em> tags).
@@ -3120,9 +3361,16 @@
3120 3361 instance.processedMessageIds.add(message.id);
3121 3362 }
3122 3363 });
3123 3364
3124 - // Only append messages and scroll if we have content
3365 + // Only append messages and scroll if we have content.
3366 + // Greeting goes back FIRST, above the restored
3367 + // history: "Hello — [earlier conversation]" is the
3368 + // natural reading and matches the order a fresh
3369 + // visitor sees (plan a1a79b).
3370 + if ($mxIntro && $mxIntro.length) {
3371 + $chatBox.append($mxIntro);
3372 + }
3125 3373 $chatBox.append($fragment);
3126 3374 scrollToBottom(botId, true);
3127 3375
3128 3376 // Re-attach live semantics AFTER the rehydration
@@ -3467,8 +3715,9 @@
3467 3715 $(this).addClass('hidden').attr('aria-expanded', 'true');
3468 3716 $badge.hide(); // Hide notification when opening chat
3469 3717 disableScroll();
3470 3718 $preChat.fadeOut(250);
3719 + mxchatFireOpened(botId);
3471 3720
3472 3721 // First open per page load: re-fetch behavior settings in case
3473 3722 // this page's inline values came from a stale full-page cache
3474 3723 // (plan-32db95). Idempotent — later opens are a no-op.
@@ -3737,9 +3986,12 @@
3737 3986 $('.chat-toolbar').show();
3738 3987 } else {
3739 3988 $('.chat-toolbar').hide();
3740 3989 }
3741 -
3990 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3991 + MxChatInstances.toolbarReady = true;
3992 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3993 +
3742 3994 // Apply toolbar icon colors
3743 3995 const toolbarElements = [
3744 3996 '#mxchat-chatbot .toolbar-btn svg',
3745 3997 '#mxchat-chatbot .active-pdf-name',
@@ -3864,10 +4116,13 @@
3864 4116 function replaceVisitorNamePlaceholder(botId, visitorName) {
3865 4117 var chatBox = getElementDOM(botId, 'chat-box');
3866 4118 if (!chatBox) return;
3867 4119
3868 - // Find the first bot message (intro message)
3869 - var introMessage = chatBox.querySelector('.bot-message');
4120 + // Find the greeting by its marker, not by position (plan a1a79b) —
4121 + // after a persistence restore the first .bot-message is a restored
4122 + // reply, and {visitor_name} was being substituted into that instead.
4123 + // Positional fallback for HTML cached before this release only.
4124 + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3870 4125 if (!introMessage) return;
3871 4126
3872 4127 var messageContent = introMessage.querySelector('div[dir="auto"]');
3873 4128 if (!messageContent) return;
@@ -4011,14 +4266,25 @@
4011 4266 return response.json();
4012 4267 })
4013 4268 .then((data) => {
4014 4269 if (data.success) {
4015 - if (data.data.logged_in || data.data.email) {
4270 + // A success response means the server has already decided the
4271 + // required info is on file. With email optional (c0cfaf) that
4272 + // info can be a name and no address, so keying on data.email
4273 + // alone re-prompted a name-only visitor on every page load.
4274 + if (data.data.logged_in || data.data.email || data.data.name) {
4016 4275 showChatContainerForBot(botId);
4017 4276 } else {
4018 4277 showEmailFormForBot(botId);
4019 4278 }
4020 4279 } else {
4280 + // Pre-fill for a signed-in visitor being shown the form
4281 + // (c0cfaf). It arrives on this nocache'd response rather than
4282 + // in the page HTML, which is cacheable. Never overwrite what
4283 + // the visitor has already typed.
4284 + if (data.data) {
4285 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4286 + }
4021 4287 showEmailFormForBot(botId);
4022 4288 }
4023 4289 })
4024 4290 .catch((error) => {
@@ -4025,8 +4291,19 @@
4025 4291 showEmailFormForBot(botId);
4026 4292 });
4027 4293 }
4028 4294
4295 + function prefillEmailForm(botId, name, email) {
4296 + var nameInput = getElementDOM(botId, 'user-name');
4297 + var emailInput = getElementDOM(botId, 'user-email');
4298 + if (nameInput && name && !nameInput.value) {
4299 + nameInput.value = name;
4300 + }
4301 + if (emailInput && email && !emailInput.value) {
4302 + emailInput.value = email;
4303 + }
4304 + }
4305 +
4029 4306 // Event delegation for email form submission
4030 4307 $(document).on('submit', '.email-collection-form', function(e) {
4031 4308 e.preventDefault();
4032 4309 e.stopPropagation();
@@ -4044,19 +4321,29 @@
4044 4321 var userEmail = emailInput ? emailInput.value.trim() : '';
4045 4322 var userName = nameInput ? nameInput.value.trim() : '';
4046 4323 var sessionId = MxChatInstances.ensureSession(botId);
4047 4324
4048 - // Validate email
4049 - if (!userEmail) {
4325 + // Validate email. The address is optional when the owner turned
4326 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4327 + // still has to be a valid address. The server enforces both rules
4328 + // independently.
4329 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4330 +
4331 + if (emailRequired && !userEmail) {
4050 4332 showEmailError(botId, 'Please enter your email address.');
4051 4333 return false;
4052 4334 }
4053 4335
4054 - if (!isValidEmailAddress(userEmail)) {
4336 + if (userEmail && !isValidEmailAddress(userEmail)) {
4055 4337 showEmailError(botId, 'Please enter a valid email address.');
4056 4338 return false;
4057 4339 }
4058 4340
4341 + if (!emailRequired && !userEmail && !userName) {
4342 + showEmailError(botId, 'Please enter your name or email address.');
4343 + return false;
4344 + }
4345 +
4059 4346 // Validate name if field exists and has content
4060 4347 if (nameInput && userName && !isValidNameInput(userName)) {
4061 4348 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4062 4349 return false;
@@ -4212,8 +4499,9 @@
4212 4499 $chatbot.removeClass('hidden').addClass('visible');
4213 4500 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4214 4501 handlePreChatDismissal(botId);
4215 4502 disableScroll(); // Disable scroll when chatbot opens
4503 + mxchatFireOpened(botId);
4216 4504
4217 4505 // Load chat history for returning visitors (persistence)
4218 4506 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4219 4507 if (chatPersistenceEnabled) {
@@ -4320,14 +4608,8 @@
4320 4608 if (messageDiv.length > 0) {
4321 4609 const originalHref = $link.attr('href');
4322 4610
4323 4611 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
4324 - e.preventDefault();
4325 - e.stopPropagation();
4326 -
4327 - // Mark as tracked
4328 - $link.attr('data-tracked', 'true');
4329 -
4330 4612 // Get bot ID from the chat box context
4331 4613 var botId = getBotIdFromElement(this);
4332 4614
4333 4615 // Get message context from the message div
@@ -4332,28 +4614,12 @@
4332 4614
4333 4615 // Get message context from the message div
4334 4616 const messageText = messageDiv.text().substring(0, 200);
4335 4617
4336 - $.ajax({
4337 - url: mxchatChat.ajax_url,
4338 - type: 'POST',
4339 - data: {
4340 - action: 'mxchat_track_url_click',
4341 - session_id: getChatSession(botId),
4342 - url: originalHref,
4343 - message_context: messageText,
4344 - nonce: mxchatChat.nonce
4345 - },
4346 - complete: function() {
4347 - if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
4348 - window.open(originalHref, '_blank');
4349 - } else {
4350 - window.location.href = originalHref;
4351 - }
4352 - }
4353 - });
4354 -
4355 - return false;
4618 + // Same path as attachLinkTracking(): set the target, record the
4619 + // click, and let the browser follow the anchor.
4620 + prepareTrackedLink($link);
4621 + trackLinkClick(originalHref, messageText, botId);
4356 4622 }
4357 4623 }
4358 4624 });
4359 4625
@@ -4379,8 +4645,15 @@
4379 4645 // Initialize chat visibility for all instances
4380 4646 $('.mxchat-chatbot-wrapper').each(function() {
4381 4647 var botId = $(this).data('bot-id') || 'default';
4382 4648 initializeChatVisibility(botId);
4649 + // An embedded widget is on screen from the first paint; a floating one
4650 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4651 + if (isEmbeddedBot(botId)) {
4652 + mxchatFireOpened(botId);
4653 + } else {
4654 + mxchatWatchForOpen(botId);
4655 + }
4383 4656 });
4384 4657
4385 4658 // Make functions globally available for add-ons
4386 4659 window.hasQuickQuestions = hasQuickQuestions;