jQuery(document).ready(function($) { // Nonce refresh — v2 (plan-6a68c9). // // The widget no longer relies on a nonce embedded in inline cached HTML. // Before each chat-send / stream-send / upload, we call the REST endpoint // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The // endpoint creates the nonce with action `mxchat_chat_send`; the server-side // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a // 30-day backwards-compat window so cached pages still in users' browsers // (which carry the legacy inline-localized nonce) keep working. // // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are // 24h but we refetch at half-life so a freshly-cached-page user never sees // a borderline-stale nonce). var cachedFreshNonce = null; var cachedFreshNonceFetchedAt = 0; var NONCE_TTL_MS = 12 * 60 * 60 * 1000; var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done' var nonceRefreshCallbacks = []; function getRestNonceUrl() { if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) { return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce'; } // Fallback: derive from current origin if mxchatChat.rest_url isn't set. return window.location.origin + '/wp-json/mxchat/v1/nonce'; } function fetchFreshNonceFromRest() { return fetch(getRestNonceUrl(), { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }).then(function (resp) { if (!resp.ok) { throw new Error('REST nonce fetch failed: ' + resp.status); } return resp.json(); }).then(function (data) { if (data && data.nonce) { return data.nonce; } throw new Error('REST nonce response had no nonce field.'); }); } /** * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh. * Tries REST endpoint first (cache-bypass design); falls back to the legacy * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent * calls share the same in-flight refresh. */ function withFreshNonce(callback) { if (typeof mxchatChat === 'undefined') { if (callback) callback(); return; } var now = Date.now(); if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) { mxchatChat.nonce = cachedFreshNonce; if (callback) callback(); return; } if (callback) nonceRefreshCallbacks.push(callback); if (nonceRefreshState === 'pending') return; nonceRefreshState = 'pending'; var resolved = function (nonce) { if (nonce) { cachedFreshNonce = nonce; cachedFreshNonceFetchedAt = Date.now(); mxchatChat.nonce = nonce; } nonceRefreshState = 'done'; var pending = nonceRefreshCallbacks; nonceRefreshCallbacks = []; pending.forEach(function (cb) { try { cb(); } catch (e) {} }); }; fetchFreshNonceFromRest() .then(resolved) .catch(function () { // Fallback to the legacy admin-ajax refresh path (issued with the // old action `mxchat_chat_nonce`; the server still accepts both // during the compat window). if (mxchatChat.ajax_url) { $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }) .done(function (res) { if (res && res.success && res.data && res.data.nonce) { resolved(res.data.nonce); return; } resolved(null); }) .fail(function () { resolved(null); }); } else { resolved(null); } }); } // Backwards-compat alias — every existing caller in this file (and any // out-of-tree consumer that hit this internal API) keeps working unchanged. function refreshNonceIfNeeded(callback) { return withFreshNonce(callback); } // Dynamic-settings refresh (plan-32db95). // // Every widget setting ships inline in cached page HTML, so behind a // full-page cache the site owner can't purge (host cache, CDN, the // browser itself) a toggled setting looks broken until the cache turns // over. Same distrust-cached-HTML reasoning as the per-request nonce: // on the FIRST widget open per page load we ask the nonce endpoint for // the current behavior-gate settings (?with_settings=1), merge them over // mxchatChat, and rebuild the header menu. Colors are NOT refreshed — // they're server-inline-styled, so a runtime swap would visibly flash. // On any failure we keep the inline values silently (nonce-fallback // posture). At most one request per page load, only if a widget opens. var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done' function mxchatRefreshDynamicSettings() { if (dynamicSettingsState !== 'idle') return; if (typeof mxchatChat === 'undefined') return; dynamicSettingsState = 'pending'; var applied = function (data) { dynamicSettingsState = 'done'; if (!data) return; // endpoint unavailable — inline values stand. if (data.nonce) { // Seed the nonce cache too: saves the first send's REST // round-trip and keeps us under the endpoint's rate limit. cachedFreshNonce = data.nonce; cachedFreshNonceFetchedAt = Date.now(); mxchatChat.nonce = data.nonce; } if (data.settings && typeof data.settings === 'object') { $.extend(mxchatChat, data.settings); mxchatRebuildHeaderMenus(); } }; fetch(getRestNonceUrl() + '?with_settings=1', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }).then(function (resp) { if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status); return resp.json(); }).then(applied).catch(function () { // Fallback: legacy admin-ajax refresh path, same as withFreshNonce. if (mxchatChat.ajax_url) { $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 }) .done(function (res) { applied(res && res.success && res.data ? res.data : null); }) .fail(function () { applied(null); }); } else { applied(null); } }); } // ==================================== // MULTI-INSTANCE MANAGEMENT SYSTEM // ==================================== // Instance registry - tracks all chatbot instances on the page const MxChatInstances = { instances: {}, // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The // events below fire once; a script that loads late checks these instead. toolbarReady: false, opened: {}, hasOpened: function(botId) { return !!this.opened[botId || 'default']; }, // Initialize an instance for a bot init: function(botId) { if (!this.instances[botId]) { // When persistence is OFF, track when this session started // so the AI only sees messages from this page load var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on'; this.instances[botId] = { botId: botId, sessionId: null, lastSeenMessageId: '', notificationCheckInterval: null, pollingInterval: null, processedMessageIds: new Set(), activePdfFile: null, activeWordFile: null, chatHistoryLoaded: false, isStreaming: false, // Fresh context timestamp - only used when persistence is OFF sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now() }; } return this.instances[botId]; }, // Get instance by botId get: function(botId) { return this.instances[botId] || this.init(botId); }, // Get all active bot IDs getAllBotIds: function() { return Object.keys(this.instances); }, // Session management per bot // Returns existing session ID from cookie or localStorage (with in-memory fallback), // or null if none exists. Does NOT create a new session — use ensureSession() for that. getChatSession: function(botId) { var cookieName = 'mxchat_session_id_' + botId; var storageKey = 'mxchat_session_id_' + botId; var sessionId = getCookie(cookieName); // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent) if (!sessionId) { try { sessionId = localStorage.getItem(storageKey); } catch (e) {} } // Fallback to in-memory instance when cookie AND localStorage are both blocked // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned // storage). Without this, ensureSession() can generate and store an ID that // getChatSession() then can't read back, causing null session_ids on send. if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) { sessionId = this.instances[botId].sessionId; } // Guard against stored sentinel values that indicate earlier broken writes. if (sessionId === 'null' || sessionId === 'undefined') { sessionId = null; } // Re-sync cookie from localStorage if cookie was lost if (sessionId && !getCookie(cookieName)) { document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax"; } return sessionId || null; }, // Lazy session initializer — called on first user interaction ensureSession: function(botId) { botId = botId || 'default'; var instance = this.instances[botId] || this.init(botId); if (instance.sessionId) { return instance.sessionId; } // Check for existing session from cookie or localStorage var existingSession = this.getChatSession(botId); if (existingSession) { instance.sessionId = existingSession; } else { // Brand new session var newId = generateSessionId(); this.setChatSession(botId, newId); instance.sessionId = newId; } // Now that we have a session, do the deferred work refreshNonceIfNeeded(); trackOriginatingPage(); // Note: loadChatHistory is handled by showChatContainerForBot with loader UI, // so we do NOT call it here to avoid a race condition. return instance.sessionId; }, setChatSession: function(botId, sessionId) { var cookieName = 'mxchat_session_id_' + botId; var storageKey = 'mxchat_session_id_' + botId; document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax"; try { localStorage.setItem(storageKey, sessionId); } catch (e) {} if (this.instances[botId]) { this.instances[botId].sessionId = sessionId; } }, resetChatSession: function(botId) { // Clear old session from localStorage before setting new one try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {} var newSessionId = generateSessionId(); this.setChatSession(botId, newSessionId); var $chatBox = getElement(botId, 'chat-box'); if ($chatBox.length) { // Keep the greeting, drop everything else. Identify the greeting // by its marker class, NOT by position (plan a1a79b): after a // chat-persistence restore the first .bot-message is a real // reply, so ":not(:first)" left a stale answer sitting at the // top of an otherwise empty box. The positional fallback only // runs when the marker is absent — a page served from HTML cache // that predates this release — and behaves exactly as before. if ($chatBox.find('.mxchat-intro-message').length) { $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove(); } else { $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove(); } } if (this.instances[botId]) { this.instances[botId].chatHistoryLoaded = false; this.instances[botId].processedMessageIds = new Set(); } }, // Silent reset — new session ID without clearing the chat UI // Used when IP changes mid-conversation so the user doesn't see messages vanish silentResetSession: function(botId) { try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {} var newSessionId = generateSessionId(); this.setChatSession(botId, newSessionId); if (this.instances[botId]) { this.instances[botId].sessionId = newSessionId; } return newSessionId; } }; // ==================================== // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6) // ==================================== // // Document-level CustomEvents an add-on listens for instead of polling the // DOM or firing requests on page load: // mxchat:toolbar-ready detail { enabled } once, right after the // toolbar show/hide pass; items rendered through the // mxchat_chat_toolbar_items filter can bind now. // mxchat:opened detail { botId, embedded } once per bot per page // load, the first time its window is shown — the // launcher, the pre-chat bubble, an embedded widget // (at load), or any script that reveals it. // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId). function mxchatDispatch(name, detail) { try { document.dispatchEvent(new CustomEvent(name, { detail: detail || {} })); } catch (err) { /* CustomEvent unsupported — nothing to notify */ } } function mxchatFireOpened(botId) { botId = botId || 'default'; if (MxChatInstances.opened[botId]) return; MxChatInstances.opened[botId] = true; mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) }); } // Catch openers this file does not own (the Trigger add-on, theme buttons, // custom scripts): a floating window gaining .visible counts as opened. function mxchatWatchForOpen(botId) { var el = getElementDOM(botId, 'floating-chatbot'); if (!el || typeof MutationObserver === 'undefined') return; if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; } var obs = new MutationObserver(function() { if (el.classList.contains('visible')) { mxchatFireOpened(botId); obs.disconnect(); } }); obs.observe(el, { attributes: true, attributeFilter: ['class'] }); } // ==================================== // ELEMENT SELECTOR HELPERS // ==================================== // Check if a specific bot has an AI theme assigned (skip inline colors) function shouldSkipInlineColors(botId) { // If global AI theme is active, skip inline colors for all bots if (mxchatChat.skip_inline_colors) { return true; } // Check if this specific bot has a theme assignment var botAssignments = mxchatChat.bot_theme_assignments || {}; return botAssignments.hasOwnProperty(botId); } // Get element by ID with bot suffix - returns jQuery object function getElement(botId, elementName) { return $('#' + elementName + '-' + botId); } // Get element by ID with bot suffix - returns DOM element function getElementDOM(botId, elementName) { return document.getElementById(elementName + '-' + botId); } // Get bot ID from any element within a chatbot instance function getBotIdFromElement(element) { var $wrapper = $(element).closest('.mxchat-chatbot-wrapper'); if ($wrapper.length) { return $wrapper.data('bot-id') || 'default'; } // Fallback: try to find from floating container var $floating = $(element).closest('.floating-chatbot'); if ($floating.length) { var id = $floating.attr('id') || ''; var match = id.match(/floating-chatbot-(.+)/); if (match) return match[1]; } // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the // .close-pre-chat-message button, which carries only a class and no id — miss both // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id} // and read the suffix. (closest() includes the element itself, so a click directly on // #pre-chat-message-{bot_id} resolves here too.) var $preChat = $(element).closest('[id^="pre-chat-message-"]'); if ($preChat.length) { var preId = $preChat.attr('id') || ''; var preMatch = preId.match(/^pre-chat-message-(.+)$/); if (preMatch) return preMatch[1]; } // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id}) var elementId = $(element).attr('id') || ''; if (elementId) { // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id} var idMatch = elementId.match(/^(?:floating-chatbot-button|pre-chat-message|chat-notification-badge)-(.+)$/); if (idMatch) return idMatch[1]; } return 'default'; } // Get wrapper element for a bot function getWrapper(botId) { return getElement(botId, 'mxchat-chatbot-wrapper'); } // ==================================== // GLOBAL VARIABLES & CONFIGURATION // ==================================== const toolbarIconColor = mxchatChat.toolbar_icon_color || '#212121'; // Initialize color settings (these are global as they come from PHP) var userMessageBgColor = mxchatChat.user_message_bg_color; var userMessageFontColor = mxchatChat.user_message_font_color; var botMessageBgColor = mxchatChat.bot_message_bg_color; var botMessageFontColor = mxchatChat.bot_message_font_color; var liveAgentMessageBgColor = mxchatChat.live_agent_message_bg_color; var liveAgentMessageFontColor = mxchatChat.live_agent_message_font_color; var linkTarget = mxchatChat.link_target_toggle === 'on' ? '_blank' : '_self'; // ==================================== // SESSION MANAGEMENT (Legacy compatibility) // ==================================== function getCookie(name) { let value = "; " + document.cookie; let parts = value.split("; " + name + "="); if (parts.length == 2) return parts.pop().split(";").shift(); } function generateSessionId() { // Session IDs function as the de-facto bearer token for an anonymous // chat, so generate them with a CSPRNG when available. Math.random is a // legacy fallback for ancient/sandboxed environments that lack // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other // code pattern-matches on it). (plan-0c17b5) var rand; try { if (window.crypto && window.crypto.getRandomValues) { var buf = new Uint8Array(16); // 128 bits window.crypto.getRandomValues(buf); rand = Array.prototype.map.call(buf, function (b) { return ('0' + b.toString(16)).slice(-2); }).join(''); } } catch (e) {} if (!rand) { rand = Math.random().toString(36).substr(2, 9); // legacy fallback } return 'mxchat_chat_' + rand; } // Legacy function - now delegates to instance manager function getChatSession(botId) { botId = botId || 'default'; return MxChatInstances.getChatSession(botId); } function setChatSession(sessionId, botId) { botId = botId || 'default'; MxChatInstances.setChatSession(botId, sessionId); } function resetChatSession(botId) { botId = botId || 'default'; MxChatInstances.resetChatSession(botId); } // ==================================== // INITIALIZE ALL CHATBOT INSTANCES // ==================================== function initializeAllInstances() { // Find all chatbot wrappers on the page $('.mxchat-chatbot-wrapper').each(function() { var botId = $(this).data('bot-id') || 'default'; MxChatInstances.init(botId); initializeBotInstance(botId); }); } function initializeBotInstance(botId) { var instance = MxChatInstances.get(botId); // Initialize quick questions state for this bot checkQuickQuestionsState(botId); // Note: Event handlers use event delegation with class selectors, // so they work automatically for all instances without per-bot setup } // ==================================== // CONTEXTUAL AWARENESS FUNCTIONALITY // ==================================== // Plan f3e515: the page context is computed once per page, not once per message. // Keyed on URL + title + the content element's text length — cheap to check, // and enough to notice a navigation or a re-rendered content area. var pageContextCache = { key: null, value: null }; function getPageContext() { // Check if contextual awareness is enabled if (mxchatChat.contextual_awareness_toggle !== 'on') { return null; } // Get page URL const pageUrl = window.location.href; // Get page title const pageTitle = document.title || ''; // Get main content from the page let pageContent = ''; // Try to get content from common content areas const contentSelectors = [ 'main', '[role="main"]', '.content', '.main-content', '.post-content', '.entry-content', '.page-content', 'article', '#content', '#main' ]; let contentElement = null; for (const selector of contentSelectors) { contentElement = document.querySelector(selector); if (contentElement) { break; } } // If no specific content area found, use body but exclude header, footer, nav, sidebar if (!contentElement) { contentElement = document.body; } if (contentElement) { const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length; if (pageContextCache.key === cacheKey) { return pageContextCache.value; } // Copy the element into a document that has no browsing context // (plan f3e515). A live cloneNode(true) constructed real