1/* @license GPL-2.0-or-later https://www.drupal.org/licensing/faq */ 2/** 3 * @file 4 * MX Chatbot bootstrap (WEM-6967). 5 * 6 * Runs in <head>, BEFORE GTM/lpTag. Ships as a static, cache-safe file that is 7 * identical for every visitor. It reads the RAW schedule from 8 * drupalSettings.mxChatbot and computes the live ON/OFF decision in the browser 9 * against the browser clock (evaluated in America/New_York), so an edge-cached 10 * page can never serve a stale decision. 11 * 12 * Ordering: the hold (window._lptStop = true) is set by a tiny INLINE 13 * html_head script emitted by hook_page_attachments with a strongly negative 14 * weight, so it runs BEFORE the google_tag GTM/lpTag snippet (an html_head tag 15 * at weight >= 9). A header:true library alone renders AFTER html_head snippets 16 * and would set the flag too late. This library therefore does NOT rely on 17 * being first to set the hold - it re-affirms it defensively and is responsible 18 * for RELEASING it (window._lptStop = undefined) when the bot should be OFF or 19 * on /es/* pages. 20 * 21 * Behavior: 22 * - The inline snippet has already set window._lptStop = true (LP held off). 23 * - On /es/* pages: release the hold and never load the bot (LivePerson runs). 24 * - Otherwise compute ET state: enabled -> blackout -> weekday -> [botOn,botOff). 25 * - ON: keep _lptStop = true (LivePerson suppressed), then apply the OneTrust 26 * consent gate before injecting the AI widget (async, defer, data-chat-url). 27 * - OFF: release the hold (window._lptStop = undefined) so LivePerson loads. 28 * 29 * CONSENT GATE (WEM-6967, opt-out per WEM-7024): the AI widget is gated behind 30 * OneTrust category C0003, mirroring how mm_ctm/js/ctm-snippet.js gates CTM. 31 * The site operates OPT-OUT: OneTrust seeds its default category list 32 * (including C0003) into window.OnetrustActiveGroups BEFORE the visitor makes a 33 * choice, so an UNDECIDED visitor already has C0003 present and the widget 34 * LOADS for them. The gate only stays closed once the visitor has ACTIVELY 35 * REMOVED C0003 (reject-all / Functional off), which drops C0003 out of the 36 * groups string. This matches the bare substring check mm_ctm and mm_privacy 37 * use (no IsAlertBoxClosed() requirement). On the bot-ON branch, when the 38 * feature is gated (settings.consentGated) the widget is injected as soon as 39 * settings.consentCategory (C0003) is present in window.OnetrustActiveGroups. 40 * We reuse the same wait strategy mm_privacy uses everywhere: subscribe to 41 * window.OneTrust.OnConsentChanged for in-session changes and poll on 42 * requestAnimationFrame for consent already stored from a prior visit 43 * (OnConsentChanged does not fire for that), giving up after 10s. When the 44 * feature is exempted on the mm_privacy settings form 45 * (settings.consentGated === false) the widget injects immediately. 46 * 47 * _lptStop DURING THE WAIT: throughout a bot-ON window the hold is kept 48 * (window._lptStop = true) even while we wait for (or never receive) consent. 49 * We deliberately do NOT release the hold to let LivePerson load as a fallback: 50 * LivePerson's own chat widget is not itself gated on C0003, so releasing the 51 * hold mid-window would flash an ungated chat in place of the consent-gated 52 * bot. The safe default is that without consent NOTHING chat-related loads 53 * during a bot window; LivePerson resumes normally only in bot-OFF windows, 54 * where the hold is released exactly as before this change (so LP's existing 55 * consent behavior is preserved and un-regressed). 56 * - Fire navigator.sendBeacon to the transition endpoint ONLY when the observed 57 * state differs from the last recorded state (localStorage-deduped). 58 * 59 * DEBUG LOGGING: all diagnostic output is gated behind a debug flag and OFF by 60 * default (no console spam in production). Turn it on any of these ways: 61 * - Admin: MX Chatbot settings form -> "Enable debug logging" checkbox 62 * (ships drupalSettings.mxChatbot.debug = true). 63 * - URL param: append ?mxdebug=1 (or &mxdebug=1) to any page. ?mxdebug=0 forces 64 * it off for that page load. 65 * - localStorage: run localStorage.setItem('mxChatbotDebug','1') in the console 66 * (persists across page loads until you remove it). 67 * All debug lines are prefixed "[MX Chatbot]". 68 * 69 * NOTE: no Drupal behaviors / jQuery here on purpose - this must execute in the 70 * document <head> before other scripts, with zero dependencies. 71 */ 72(function () { 73 'use strict'; 74 75 var PREFIX = '[MX Chatbot]'; 76 77 // Read the raw schedule. Prefer window.mxChatbotSettings, which the module 78 // emits as an inline <head> script (weight -1000) BEFORE this library runs. 79 // drupalSettings is emitted far down the page (in the body) and is therefore 80 // NOT reliably defined when this header:true library executes - so it is only 81 // a fallback. 82 var s = (typeof window.mxChatbotSettings !== 'undefined' && window.mxChatbotSettings) 83 ? window.mxChatbotSettings 84 : ((typeof drupalSettings !== 'undefined' && drupalSettings.mxChatbot) 85 ? drupalSettings.mxChatbot 86 : null); 87 88 // Resolve the debug flag: URL param (?mxdebug=1|0) > localStorage > setting. 89 var DEBUG = resolveDebug(s); 90 91 // Minimal gated logger. No-ops entirely unless DEBUG is on. 92 function log() { 93 if (!DEBUG) { 94 return; 95 } 96 var args = Array.prototype.slice.call(arguments); 97 args.unshift(PREFIX); 98 try { 99 (console.debug || console.log).apply(console, args); 100 } 101 catch (e) { 102 // Never let logging break the page. 103 } 104 } 105 106 /** 107 * Resolves whether debug logging is enabled. 108 * 109 * Precedence: URL param mxdebug (1/0) wins, then localStorage mxChatbotDebug, 110 * then the drupalSettings debug flag, else false. 111 * 112 * @param {Object|null} settings 113 * The mxChatbot settings (may be null). 114 * 115 * @return {boolean} 116 * TRUE if debug logging is enabled. 117 */ 118 function resolveDebug(settings) { 119 try { 120 var m = location.search.match(/[?&]mxdebug=([01])/); 121 if (m) { 122 return m[1] === '1'; 123 } 124 if (window.localStorage && localStorage.getItem('mxChatbotDebug')
124=== '1') { 125 return true; 126 } 127 } 128 catch (e) { 129 // Ignore and fall through to the setting. 130 } 131 return !!(settings && settings.debug); 132 } 133 134 log('bootstrap start', { 135 ts: new Date().toISOString(), 136 perfNow: (window.performance && performance.now) ? Math.round(performance.now()) : null, 137 path: location.pathname 138 }); 139 140 // 1) Hold LivePerson OFF until we decide. The real pre-GTM hold is set by the 141 // inline html_head snippet (weight -1000) so it beats google_tag's GTM 142 // snippet; we re-affirm it here defensively in case this library somehow 143 // runs first (e.g. the inline snippet was stripped/aggregated away). The 144 // lpTag loader is guarded by 145 // ('undefined' === typeof window._lptStop && this.load()), so a truthy 146 // value prevents the external tag.js from loading. 147 window._lptStop = true; 148 log('LivePerson held via window._lptStop=true (inline pre-GTM hold; re-affirmed by library)'); 149 150 // If no settings shipped, do not block LivePerson - release and bail. 151 if (!s) { 152 log('no drupalSettings.mxChatbot found -> releasing _lptStop, letting LivePerson load'); 153 window._lptStop = undefined; 154 return; 155 } 156 157 log('read schedule (window.mxChatbotSettings / drupalSettings.mxChatbot)', { 158 enabled: s.enabled, 159 tz: s.tz, 160 botOn: s.botOn, 161 botOff: s.botOff, 162 activeDays: s.activeDays, 163 blackouts: s.blackouts, 164 widget: s.widget, 165 chatUrl: s.chatUrl, 166 beacon: s.beacon, 167 debug: s.debug, 168 consentGated: s.consentGated, 169 consentCategory: s.consentCategory, 170 consentTimeoutMs: s.consentTimeoutMs 171 }); 172 173 // 2) Spanish exclusion. Ticket says "/yes/" but the correct prefix is /es/* 174 // (Spanish pages do not run LivePerson today, and never load the bot). 175 if (/^\/es\//.test(location.pathname)) { 176 log('/es/* check: MATCHED (' + location.pathname + ') -> releasing _lptStop, letting LivePerson load, bot never injected'); 177 window._lptStop = undefined; 178 return; 179 } 180 log('/es/* check: not matched (' + location.pathname + ') -> continuing'); 181 182 // 3) Compute the live ET state in the browser (with per-rung logging). 183 // Wrapped in a fail-safe: any unexpected error must NOT leave LivePerson 184 // permanently suppressed - fall back to releasing the hold so LP loads. 185 try { 186 var on = isBotActiveNowET(s, new Date(), true); 187 188 if (s.enabled && on) { 189 // Bot window: keep LivePerson suppressed (hold already true) and route the 190 // widget through the OneTrust consent gate. We record the bot_on 191 // transition regardless of consent - the observed schedule state is 192 // "bot on", independent of whether consent has (yet) allowed the widget 193 // to load - so the admin transition log stays an accurate flip log. 194 log('FINAL decision: bot ON -> keeping _lptStop=true (LivePerson suppressed), applying consent gate before inject'); 195 injectWidgetWhenConsented(s); 196 recordTransition(s, 'bot_on'); 197 } 198 else { 199 log('FINAL decision: bot OFF -> releasing _lptStop, LivePerson will load'); 200 window._lptStop = undefined; 201 recordTransition(s, 'bot_off'); 202 } 203 } 204 catch (err) { 205 // Fail safe: on any error, do not strand LivePerson - release the hold. 206 log('ERROR computing state -> fail-safe fallback: releasing _lptStop, LivePerson will load', err); 207 window._lptStop = undefined; 208 } 209 210 /** 211 * Applies the OneTrust consent gate, then injects the AI widget when allowed. 212 * 213 * Decision order (only reached on the bot-ON branch, LivePerson held): 214 * - Feature exempted on the mm_privacy settings form 215 * (settings.consentGated === false): inject immediately, no consent wait. 216 * - Otherwise gated (opt-out): inject as soon as the required category 217 * (settings.consentCategory, default C0003) is present in 218 * window.OnetrustActiveGroups. Because OneTrust seeds its default 219 * category list (including C0003) BEFORE the visitor decides, an 220 * UNDECIDED visitor already has C0003 present and the widget LOADS. The 221 * gate only stays closed once the visitor has ACTIVELY REMOVED C0003 222 * (reject-all / Functional off), in which case the widget is never 223 * injected and the LivePerson hold is kept (see file header). 224 * 225 * @param {Object} settings 226 * The mxChatbot settings. 227 */ 228 function injectWidgetWhenConsented(settings) { 229 // Exempted: mm_privacy admin removed this feature's gate, so load now. 230 if (settings.consentGated === false) { 231 log('consent gate: feature EXEMPT (mm_privacy ungatedFeatures) -> injecting widget immediately, no consent wait'); 232 injectWidget(settings); 233 return; 234 } 235 236 var category = settings.consentCategory || 'C0003'; 237 log('consent gate: feature GATED on ' + category + ' -> waiting for OneTrust consent before inject', { 238 onetrustActiveGroups: (typeof window.OnetrustActiveGroups === 'string') ? window.OnetrustActiveGroups : null 239 }); 240 241 whenConsentGranted(category, function () { 242 log('consent gate: ' + category + ' present in OnetrustActiveGroups -> injecting widget', { 243 onetrustActiveGroups: window.OnetrustActiveGroups 244 }); 245 injectWidget(settings); 246 }, settings); 247 } 248 249 /** 250 * Reports whether OneTrust has actually resolved/applied a consent decision. 251 * 252 * OPT-OUT NOTE (WEM-7024): this is NO LONGER used to gate widget injection. 253 * Under opt-out the widget loads for undecided visitors (see hasConsent()), 254 * so a resolved decision is not required to inject. This helper survives only 255 * as one input to the WEM-7024 never-init detection (oneTrustResolved() in 256 * whenConsentGranted()): it distinguishes "OneTrust applied a real decision" 257 * from "OneTrust never initialized", so the schedule-gated fallback fires only 258 * in the genuine never-init case and not when a decision has actually been 259 * applied. 260 * 261 * window.OneTrust.IsAlertBoxClosed() returns TRUE only once OneTrust has a 262 * resolved consent decision (banner interacted with or a stored decision 263 * applied). 264 * 265 * @return {boolean} 266 * TRUE once OneTrust has applied a real consent decision. 267 */ 268 function consentResolved() { 269 try { 270 return !!( 271 window.OneTrust && 272 typeof window.OneTrust.IsAlertBoxClosed === 'function' && 273 window.OneTrust.IsAlertBoxClosed() === true 274 ); 275 } 276 catch (e) { 277 return false;
278 } 279 } 280 281 /** 282 * Reports whether the given OneTrust category is currently consented to. 283 * 284 * OPT-OUT (WEM-7024): the site operates opt-out, so this is a BARE substring 285 * test against window.OnetrustActiveGroups - the same idiom mm_ctm 286 * (mmHasFunctionalConsent) and mm_privacy (consentState) use, with NO 287 * IsAlertBoxClosed()/consentResolved() requirement. OneTrust seeds its 288 * default category list (including C0003) into OnetrustActiveGroups BEFORE 289 * the visitor decides, so this returns TRUE for UNDECIDED and GRANTED 290 * visitors (the widget loads by default) and only returns FALSE once the 291 * visitor has ACTIVELY REMOVED the category (reject-all / Functional off), 292 * which drops it out of the groups string. That is the opt-out semantic: the 293 * AI widget stays out only when consent has been affirmatively withdrawn. 294 * 295 * @param {string} category 296 * The OneTrust category id, e.g. 'C0003'. 297 * 298 * @return {boolean} 299 * TRUE if the category is present in window.OnetrustActiveGroups. 300 */ 301 function hasConsent(category) { 302 return ( 303 typeof window.OnetrustActiveGroups === 'string' && 304 window.OnetrustActiveGroups.indexOf(category) !== -1 305 ); 306 } 307 308 /** 309 * Runs a callback once OneTrust consent for a category is granted. 310 * 311 * Mirrors mm_ctm/js/ctm-snippet.js mmWhenFunctionalConsent(): it reacts to 312 * consent changes the visitor makes during this session via 313 * window.OneTrust.OnConsentChanged, and polls on requestAnimationFrame to 314 * cover consent already stored from a prior visit (OnConsentChanged does not 315 * fire for that). The callback runs at most once, and never if consent is not 316 * granted within the timeout (settings.consentTimeoutMs, default 1000ms), so 317 * the AI widget is never injected without consent. 318 * 319 * @param {string} category 320 * The OneTrust category id to wait for, e.g. 'C0003'. 321 * @param {Function} callback 322 * Invoked once when consent for the category is present. 323 * @param {Object} settings 324 * The mxChatbot settings; settings.consentTimeoutMs (ms) bounds the wait. 325 */ 326 function whenConsentGranted(category, callback, settings) { 327 // Opt-out (WEM-7024): OneTrust seeds its default groups (incl. C0003) within 328 // ~1s of load, so the fast path injects almost immediately for undecided and 329 // granted visitors. This timeout now only bounds the two edge outcomes -- a 330 // genuine C0003 removal (reject -> hold) and the never-init fallback -- so a 331 // short window is ample headroom for OneTrust to seed groups on a normal 332 // load while keeping those edge cases from lingering the old 10s. 333 // 334 // Admin-configurable via settings.consentTimeoutMs (ChatSwapManager -> 335 // drupalSettings.mxChatbot). Keep a 1000ms fallback for payloads that 336 // predate the field or ship a non-numeric/negative value. 337 var TIMEOUT_MS = (settings && typeof settings.consentTimeoutMs === 'number' && settings.consentTimeoutMs >= 0) 338 ? settings.consentTimeoutMs 339 : 1000; 340 var fired = false; 341 342 // WEM-7024: Distinguish "OneTrust never resolved a consent decision" from 343 // "OneTrust resolved a decision but the category was declined". 344 // 345 // The MERE EXISTENCE of window.OneTrust (SDK object with IsAlertBoxClosed) 346 // is NOT enough: on live, the OneTrust script object loads but consent can 347 // stay UNRESOLVED for the whole window -- IsAlertBoxClosed() never returns 348 // true and OnetrustActiveGroups stays null. That is the "never initialized 349 // consent" state the fallback is for, so it must NOT be treated as 350 // "initialized". We consider consent RESOLVED only when OneTrust reports a 351 // real applied decision (IsAlertBoxClosed() === true) OR it has actually 352 // populated a non-empty active-groups string. Otherwise it never resolved. 353 var oneTrustResolved = function () { 354 if (consentResolved()) { 355 return true; 356 } 357 return ( 358 typeof window.OnetrustActiveGroups === 'string' && 359 window.OnetrustActiveGroups.replace(/,/g, '').length > 0 360 ); 361 }; 362 363 var run = function () { 364 if (!fired && hasConsent(category)) { 365 fired = true; 366 callback(); 367 } 368 }; 369 370 // Fast path: consent already resolved when we get here. 371 if (hasConsent(category)) { 372 log('consent gate: ' + category + ' already present at gate time'); 373 run(); 374 return; 375 } 376 log('consent gate: ' + category + ' not yet present -> subscribing to OnConsentChanged + polling (<=' + TIMEOUT_MS + 'ms)'); 377 378 // React to consent changes the visitor makes during this page session. 379 try { 380 if (window.OneTrust && typeof window.OneTrust.OnConsentChanged === 'function') { 381 window.OneTrust.OnConsentChanged(function () { 382 if (!fired && hasConsent(category)) { 383 log('consent gate: OnConsentChanged -> ' + category + ' now granted'); 384 } 385 run(); 386 }); 387 } 388 } 389 catch (e) { 390 // Never let a OneTrust API quirk break the page; the poll still covers us. 391 log('consent gate: OnConsentChanged subscribe failed (ignored), relying on poll', e); 392 } 393 394 // Poll until OneTrust has populated consent (covers already-stored consent 395 // on load), giving up after TIMEOUT_MS so the loop never runs forever. 396 var start = (window.performance && performance.now) ? performance.now() : Date.now();
397 var elapsed = function () { 398 var nowMs = (window.performance && performance.now) ? performance.now() : Date.now(); 399 return nowMs - start; 400 }; 401 var poll = function () { 402 run(); 403 if (fired) { 404 return; 405 } 406 if (elapsed() < TIMEOUT_MS) { 407 window.requestAnimationFrame(poll); 408 return; 409 } 410 411 // Timed out. Two distinct end states (WEM-7024, opt-out): 412 if (oneTrustResolved()) { 413 // OneTrust RESOLVED a real consent decision and the category is absent 414 // from the groups string -> the visitor ACTIVELY REMOVED it (reject-all 415 // / Functional off). This is the ONLY hold case under opt-out: keep the 416 // widget out and hold LivePerson. Do NOT regress consent. 417 log('consent gate: gave up waiting for ' + category + ' after ' + TIMEOUT_MS + 'ms (OneTrust resolved a decision, ' + category + ' actively removed) -> widget NOT injected, _lptStop held (no chat loads)'); 418 return; 419 } 420 421 // OneTrust NEVER resolved a consent decision within the window 422 // (IsAlertBoxClosed never true AND OnetrustActiveGroups null/empty -- 423 // outage, blocked script, cold-cache race, or the CMP simply never 424 // applied a decision). Fall back to injecting the AI widget anyway, but 425 // ONLY if the schedule still says the bot should be ON right now 426 // (re-checked at fire time so we never inject outside the bot window). 427 if (settings && isBotActiveNowET(settings, new Date(), false)) { 428 log('consent gate: OneTrust never resolved consent after ' + TIMEOUT_MS + 'ms -> FALLBACK: schedule still bot-ON, injecting AI widget (WEM-7024)'); 429 injectWidget(settings); 430 } 431 else { 432 log('consent gate: OneTrust never resolved consent after ' + TIMEOUT_MS + 'ms but schedule no longer bot-ON -> widget NOT injected (WEM-7024)'); 433 } 434 }; 435 window.requestAnimationFrame(poll); 436 } 437 438 /** 439 * Injects the AI intake widget script (async + defer + data-chat-url). 440 * 441 * @param {Object} settings 442 * The mxChatbot settings. 443 */ 444 function injectWidget(settings) { 445 // Guard against double injection (e.g. bfcache / repeated evaluation). 446 if (document.querySelector('script[data-mx-chatbot="1"]')) { 447 log('injectWidget: widget already present, skipping duplicate injection'); 448 return; 449 } 450 log('injecting AI widget', { src: settings.widget, 'data-chat-url': settings.chatUrl }); 451 var el = document.createElement('script'); 452 el.src = settings.widget; 453 el.async = true; 454 el.defer = true; 455 el.setAttribute('data-chat-url', settings.chatUrl); 456 el.setAttribute('data-mx-chatbot', '1'); 457 // Load/error listeners so Luke can see whether the external widget resolved. 458 el.addEventListener('load', function () { 459 log('AI widget LOADED', { src: settings.widget }); 460 }); 461 el.addEventListener('error', function () { 462 log('AI widget FAILED to load', { src: settings.widget }); 463 }); 464 (document.head || document.documentElement).appendChild(el); 465 } 466 467 /** 468 * Fires a deduped transition beacon when the observed state changes. 469 * 470 * @param {Object} settings 471 * The mxChatbot settings. 472 * @param {string} state 473 * Either 'bot_on' or 'bot_off'. 474 */ 475 function recordTransition(settings, state) { 476 try { 477 if (window.localStorage && localStorage.getItem('mxChatbotState') === state) { 478 // No change since last observation - no beacon. 479 log('beacon: skipped (deduped) - observed state unchanged', { state: state }); 480 return; 481 } 482 if (window.localStorage) { 483 localStorage.setItem('mxChatbotState', state); 484 } 485 if (navigator && typeof navigator.sendBeacon === 'function') { 486 navigator.sendBeacon( 487 settings.beacon, 488 JSON.stringify({ state: state, ts: Date.now() }) 489 ); 490 log('beacon: sent transition', { state: state, endpoint: settings.beacon }); 491 } 492 else { 493 log('beacon: sendBeacon unavailable, not sent', { state: state }); 494 } 495 } 496 catch (e) {
497 // Beacon is best-effort; never break the page. 498 log('beacon: error (ignored)', e); 499 } 500 } 501 502 /** 503 * Computes whether the bot should be active now, evaluated in ET. 504 * 505 * Order: master enabled -> blackout date -> weekday in activeDays -> 506 * [botOn, botOff). DST is handled automatically because the ET wall-clock is 507 * derived via Intl.DateTimeFormat in the America/New_York timezone. 508 * 509 * @param {Object} settings 510 * The mxChatbot settings. 511 * @param {Date} now 512 * The current instant (a Date object). 513 * @param {boolean} trace 514 * When true, emit gated per-rung debug logging for each decision step. 515 * 516 * @return {boolean} 517 * TRUE if the bot should be ON. 518 */ 519 function isBotActiveNowET(settings, now, trace) { 520 function step() { 521 if (trace) { 522 log.apply(null, arguments); 523 } 524 } 525 526 if (!settings.enabled) { 527 step('decision[1] master enabled? false -> bot OFF'); 528 return false; 529 } 530 step('decision[1] master enabled? true'); 531 532 var parts = etParts(now, settings.tz || 'America/New_York'); 533 var todayIso = parts.year + '-' + pad(parts.month) + '-' + pad(parts.day); 534 step('computed ET now', { 535 tz: settings.tz || 'America/New_York', 536 date: todayIso, 537 time: pad(parts.hour) + ':' + pad(parts.minute), 538 weekday: parts.weekday 539 }); 540 541 // Blackout date supersedes everything. 542 var blackouts = settings.blackouts || []; 543 for (var i = 0; i < blackouts.length; i++) { 544 if (blackouts[i] === todayIso) { 545 step('decision[2] blackout hit? true (' + todayIso + ') -> bot OFF'); 546 return false; 547 } 548 } 549 step('decision[2] blackout hit? false', { today: todayIso, blackouts: blackouts }); 550 551 // Weekday must be in activeDays. 552 var days = settings.activeDays || []; 553 if (days.indexOf(parts.weekday) === -1) { 554 step('decision[3] weekday in activeDays? false (' + parts.weekday + ' not in [' + days.join(',') + ']) -> bot OFF'); 555 return false; 556 } 557 step('decision[3] weekday in activeDays? true (' + parts.weekday + ')'); 558 559 // Time window [botOn, botOff) in minutes-of-day. 560 var current = parts.hour * 60 + parts.minute; 561 var onMin = toMinutes(settings.botOn); 562 var offMin = toMinutes(settings.botOff); 563 var inWindow = current >= onMin && current < offMin; 564 step('decision[4] within [botOn,botOff)? ' + inWindow, { 565 now: pad(parts.hour) + ':' + pad(parts.minute), 566 botOn: settings.botOn, 567 botOff: settings.botOff 568 }); 569 570 return inWindow; 571 } 572 573 /** 574 * Extracts ET wall-clock parts (DST-safe) from an instant. 575 * 576 * @param {Date} date 577 * The instant to convert. 578 * @param {string} tz 579 * The IANA timezone, e.g. 'America/New_York'. 580 * 581 * @return {Object} 582 * { year, month, day, hour, minute, weekday } where weekday is a 3-letter 583 * lowercase key (sun..sat). 584 */ 585 function etParts(date, tz) { 586 var weekdayKeys = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; 587 588 // Preferred: Intl in the target timezone (handles DST automatically). 589 try { 590 var fmt = new Intl.DateTimeFormat('en-US', { 591 timeZone: tz, 592 hourCycle: 'h23', 593 year: 'numeric', 594 month: '2-digit', 595 day: '2-digit', 596 hour: '2-digit', 597 minute: '2-digit', 598 weekday: 'short' 599 }); 600 var map = {}; 601 var arr = fmt.formatToParts(date); 602 for (var i = 0; i < arr.length; i++) { 603 map[arr[i].type] = arr[i].value; 604 } 605 var wkShort = (map.weekday || '').slice(0, 3).toLowerCase(); 606 return { 607 year: parseInt(map.year, 10), 608 month: parseInt(map.month, 10), 609 day: parseInt(map.day, 10), 610 hour: parseInt(map.hour, 10) % 24, 611 minute: parseInt(map.minute, 10), 612 weekday: weekdayKeys.indexOf(wkShort) !== -1 ? wkShort : weekdayKeys[date.getDay()] 613 }; 614 } 615 catch (e) { 616 // Fallback: local time (last resort if Intl/timeZone unsupported). 617 log('etParts: Intl timezone unavailable, falling back to local time', e); 618 return { 619 year: date.getFullYear(), 620 month: date.getMonth() + 1, 621 day: date.getDate(), 622 hour: date.getHours(), 623 minute: date.getMinutes(), 624 weekday: weekdayKeys[date.getDay()] 625 }; 626 } 627 } 628 629 /** 630 * Converts an 'HH:MM' string to minutes-of-day. 631 * 632 * @param {string} hhmm 633 * The time string. 634 * 635 * @return {number} 636 * Minutes since midnight. 637 */ 638 function toMinutes(hhmm) { 639 if (!hhmm) { 640 return 0; 641 } 642 var p = String(hhmm).split(':'); 643 var h = parseInt(p[0], 10) || 0; 644 var m = parseInt(p[1], 10) || 0; 645 return h * 60 + m; 646 } 647 648 /**
649 * Zero-pads a number to two digits. 650 * 651 * @param {number} n 652 * The number. 653 * 654 * @return {string} 655 * The padded string. 656 */ 657 function pad(n) { 658 return (n < 10 ? '0' : '') + n; 659 } 660 661 // Expose the pure decision function for testing harnesses. 662 window.mxChatbotIsBotActiveNowET = isBotActiveNowET; 663})(); 664;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.