PageSourceSearch

https://www.forthepeople.com/sites/default/files/js/js_Nb9JWJI8kkp…qZIQaV7zgGLiGou5NRgWOtUmG5qt-gyvCiuMH2Jep0Q

js forthepeople.com collected 2026-09-24 08:54:22 UTC 27,023 bytes, 664 lines download raw bytes

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.