Chat Script

For CW use only

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Chat Script
// @namespace    test
// @version      2.5.29
// @description  For CW use only
// @match        https://agents.moderationinterface.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js
// @license      MIT
// @grant        window.focus
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

;(function () {
  'use strict'

  // --- CONFIGURATION ---
  const CONFIG = {
    ui: {
      imageSelector: 'img.img-thumbnail[src*="cache.moderationinterface.com"]',
      blurAmount: '20px',
      hoverText: 'Hover to reveal',

      // Element Selectors (IDs are faster than classes)
      textareaSelector: '#chat-windows-message-textarea',
      sessionSelector: 'app-session-detail span.badge.badge-secondary',
      timelineSelector: '.timeline',
    },
    server: {
      url: 'http://localhost:5000',
    },
  }

  // --- UTILITIES ---

  // Prevents function from firing too often (Performance Optimization)
  const debounce = (func, wait) => {
    let timeout
    return function (...args) {
      clearTimeout(timeout)
      timeout = setTimeout(() => func.apply(this, args), wait)
    }
  }

  // Efficiently waits for an element to appear in the DOM
  const waitForElement = (selector, timeout = 10000) => {
    return new Promise((resolve, reject) => {
      const elem = document.querySelector(selector)
      if (elem) return resolve(elem)

      const observer = new MutationObserver(() => {
        const elem = document.querySelector(selector)
        if (elem) {
          observer.disconnect()
          resolve(elem)
        }
      })

      observer.observe(document.body, { childList: true, subtree: true })

      setTimeout(() => {
        observer.disconnect()
        reject(new Error(`Timeout waiting for ${selector}`))
      }, timeout)
    })
  }

  // Fixed: Robust value setter that mimics user typing
  // This is critical for Angular validation to detect the change and enable the button
  const setNativeValue = (element, value) => {
    const valueDescriptor = Object.getOwnPropertyDescriptor(element, 'value')
    const valueSetter = valueDescriptor ? valueDescriptor.set : null

    const prototype = Object.getPrototypeOf(element)
    const prototypeValueDescriptor = Object.getOwnPropertyDescriptor(
      prototype,
      'value',
    )
    const prototypeValueSetter = prototypeValueDescriptor
      ? prototypeValueDescriptor.set
      : null

    if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
      prototypeValueSetter.call(element, value)
    } else if (valueSetter) {
      valueSetter.call(element, value)
    } else {
      element.value = value
    }

    element.dispatchEvent(new Event('input', { bubbles: true }))
    element.dispatchEvent(new Event('change', { bubbles: true }))
  }

  // --- STYLES ---
  const STYLES = `
    :root { --blur-amount: ${CONFIG.ui.blurAmount}; }
    .blurred-image { filter: blur(var(--blur-amount)); transition: filter 0.3s ease; }
    .blurred-image:hover { filter: blur(0); }
    .image-wrapper { position: relative; display: inline-block; }
    .hover-hint {
      position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
      background: rgba(0,0,0,0.7); color: white; padding: 4px 8px;
      border-radius: 4px; pointer-events: none; transition: opacity 0.3s;
    }
    .image-wrapper:hover .hover-hint { opacity: 0; }
    
    .control-panel {
      position: fixed; top: 10px; left: 150px; z-index: 9999;
      background: white; padding: 8px 12px; border-radius: 5px;
      box-shadow: 0 2px 5px rgba(0,0,0,0.2); font-family: sans-serif;
      display: flex; align-items: center; gap: 8px;
    }
    .status-dot { width: 10px; height: 10px; border-radius: 50%; background: #d93025; }
    .status-dot.connected { background: #2ecc71; }
    
    .notify-banner {
      position: fixed; top: 60px; left: 50%; transform: translateX(-50%);
      background: #4CAF50; color: white; padding: 10px 20px;
      border-radius: 4px; z-index: 10000; animation: fadeOut 3s forwards;
    }
    @keyframes fadeOut { 0% { opacity: 1; } 80% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } }
  `

  // --- UI MANAGER ---
  class UIManager {
    constructor() {
      this.browserIdentifier = this.getOrSetBrowserId()
      GM_addStyle(STYLES)
      this.renderControlPanel()
      this.debouncedBlur = debounce(this.applyImageBlur.bind(this), 200)
      this.observeDOM()
    }

    getOrSetBrowserId() {
      let id = GM_getValue('browserId')
      if (!id) {
        id = `browser_${Math.random().toString(36).substr(2, 9)}`
        GM_setValue('browserId', id)
      }
      return id
    }

    renderControlPanel() {
      const panel = document.createElement('div')
      panel.className = 'control-panel'
      panel.innerHTML = `
        <span style="font-size: 12px; font-weight: bold; text-transform: uppercase;">Status:</span>
        <div id="socket-status" class="status-dot"></div>
        <span id="socket-text" style="font-size: 13px;">Disconnected</span>
      `
      document.body.appendChild(panel)
      this.statusDot = panel.querySelector('#socket-status')
      this.statusText = panel.querySelector('#socket-text')
    }

    setConnectionStatus(isConnected) {
      if (!this.statusDot) return
      this.statusDot.classList.toggle('connected', isConnected)
      this.statusText.textContent = isConnected ? 'Connected' : 'Disconnected'
    }

    updateQueueIndicator(count) {
      if (!this.statusText) return
      if (count > 0) {
        this.statusText.textContent = `Disconnected (${count} queued)`
      } else {
        this.statusText.textContent = this.statusDot?.classList.contains(
          'connected',
        )
          ? 'Connected'
          : 'Disconnected'
      }
    }

    showNotification(msg) {
      const el = document.createElement('div')
      el.className = 'notify-banner'
      el.textContent = msg
      document.body.appendChild(el)
      setTimeout(() => el.remove(), 3000)
    }

    applyImageBlur() {
      const images = document.querySelectorAll(CONFIG.ui.imageSelector)
      images.forEach((img) => {
        if (img.closest('.image-wrapper')) return

        const wrapper = document.createElement('div')
        wrapper.className = 'image-wrapper'

        const hint = document.createElement('div')
        hint.className = 'hover-hint'
        hint.textContent = CONFIG.ui.hoverText

        img.classList.add('blurred-image')
        img.parentNode.insertBefore(wrapper, img)
        wrapper.appendChild(img)
        wrapper.appendChild(hint)
      })
    }

    observeDOM() {
      const observer = new MutationObserver((mutations) => {
        const nodesAdded = mutations.some((m) => m.addedNodes.length > 0)
        if (nodesAdded) this.debouncedBlur()
      })
      observer.observe(document.body, { childList: true, subtree: true })
    }
  }

  // --- MESSAGE MANAGER ---
  class MessageManager {
    getAgentEnNumber() {
      const el = document.querySelector('.nav-link span.ng-star-inserted')
      return el?.textContent?.match(/\(([^)]+)\)/)?.[1] || 'unknown'
    }

    getMessages() {
      const panels = Array.from(
        document.querySelectorAll(
          'app-receive-message-item, app-sent-message-item, app-sent-thirdparty-media',
        ),
      )

      // CORRECT LOGIC:
      // The DOM usually has the NEWEST messages at the top (index 0).
      // We want the first 10 elements (the most recent conversation history).
      const relevantPanels = panels.slice(0, 5)

      const messages = relevantPanels.reduce((acc, panel) => {
        const textEl = panel.querySelector('.timeline-body p')
        const text = textEl?.textContent?.trim() || ''
        const imgEl = panel.querySelector('.timeline-body img.pixelated')
        const imageUrl = imgEl?.src || null

        if ((!text && !imageUrl) || text.includes('ALERT: Low Balance'))
          return acc

        const isAgentIcon =
          panel
            .querySelector(
              '.timeline-badge.warning .material-icons, .timeline-badge.success .material-icons',
            )
            ?.textContent?.trim() === 'extension'
        const isClient =
          panel.tagName.toLowerCase() === 'app-receive-message-item'
        const isAuto = text.startsWith('Automated text:')

        let sender = 'agent'
        if (isClient && !isAgentIcon) sender = 'client'
        if (isAuto) sender = 'agent'

        // Scrape the timestamp. The <small class="text-muted"> contains an
        // <i class="far fa-clock"> followed by the date text node.
        // FontAwesome injects a Unicode glyph via CSS ::before, but some
        // environments expose it in textContent — so we grab only the text
        // node directly to avoid any leading icon characters.
        const timestampEl = panel.querySelector('.timeline-heading .text-muted')
        let timestamp = ''
        if (timestampEl) {
          // Walk child nodes and collect only plain text nodes (nodeType 3),
          // skipping the <i> element that may carry icon glyph characters.
          timestamp = Array.from(timestampEl.childNodes)
            .filter((n) => n.nodeType === Node.TEXT_NODE)
            .map((n) => n.textContent.trim())
            .filter(Boolean)
            .join(' ')
        }

        acc.push({
          text: isAuto ? text.replace('Automated text:', '').trim() : text,
          sender,
          imageUrl,
          timestamp,
        })
        return acc
      }, [])

      // CORRECT LOGIC:
      // The 'messages' array is currently [Newest -> Oldest].
      // The AI/Backend expects chronological order [Oldest -> Newest].
      // So we reverse it here.
      return messages.reverse()
    }

    getProfileInfo() {
      const getVal = (id) => document.getElementById(id)?.value?.trim() || ''

      return {
        location: getVal('moderator-city') || 'unknown',
        name: getVal('moderator-name') || 'moderator',
        age: getVal('moderator-age') || 'unspecified',
        customerAge: getVal('customer-age') || 'unspecified',
        gender: this.detectGender(),
        countryCode: this.getCountryCode(),
        customerCustom: getVal('customer-custom'),
        moderatorCustom: getVal('moderator-custom'),
        customerDescription: getVal('customer-description'),
        moderatorDescription: getVal('moderator-description'),
        customerImage:
          document.querySelector('app-customer img.img-thumbnail')?.src || null,
        moderatorImage:
          document.querySelector('app-moderator img.img-thumbnail')?.src ||
          null,
      }
    }

    detectGender() {
      const txt =
        document
          .querySelector('.alert.alert-light p')
          ?.textContent?.toLowerCase() || ''
      if (txt.includes('gay')) return 'gay'
      if (txt.includes('tran')) return 'trans'
      return 'woman'
    }

    getCountryCode() {
      const text =
        document.querySelector('.alert.alert-light p')?.textContent || ''
      if (!text) return null

      // FIX: Check for USA-EN specifically BEFORE running the general regex
      if (text.includes('USA-EN')) return 'US'

      const match = text.match(/\[?([A-Z]{2})[-_]EN\]?/)
      if (match) return match[1] === 'IR' ? 'IE' : match[1]
      return null
    }
  }

  // --- AUTO LOGIN ---

  let autoLoginInProgress = false

  async function autoLogin() {
    if (autoLoginInProgress) return
    if (!GM_getValue('autoLoginArmed', false)) return

    const loginCard = document.querySelector('div.card.card-signup')
    if (!loginCard) return

    autoLoginInProgress = true
    console.log('[AutoLogin] Login page detected — waiting for Cloudflare...')

    const verified = await new Promise((resolve) => {
      const MAX_WAIT_MS = 60000
      const POLL_MS = 300
      const deadline = Date.now() + MAX_WAIT_MS
      let checkboxClicked = false

      const isVisible = (el) => {
        if (!el) return false
        const style = window.getComputedStyle(el)
        return style.display !== 'none' && style.visibility !== 'hidden'
      }

      const poll = () => {
        const tokenInput = document.querySelector(
          'input[name="cf-turnstile-response"]',
        )
        if (tokenInput && tokenInput.value && tokenInput.value.length > 10) {
          console.log('[AutoLogin] Cloudflare token found.')
          resolve(true)
          return
        }

        const successEl = document.querySelector('div#success.cb-container')
        if (isVisible(successEl)) {
          console.log('[AutoLogin] Cloudflare verified.')
          resolve(true)
          return
        }

        const checkbox = document.querySelector(
          'ngx-turnstile .cb-lb input[type="checkbox"], .cb-lb input[type="checkbox"]',
        )
        if (checkbox && !checkbox.checked && !checkboxClicked) {
          checkboxClicked = true
          console.log('[AutoLogin] Clicking Cloudflare checkbox...')
          checkbox.click()
        }

        if (Date.now() >= deadline) {
          console.warn('[AutoLogin] Timed out waiting for Cloudflare verification.')
          resolve(false)
          return
        }

        setTimeout(poll, POLL_MS)
      }

      poll()
    })

    if (!verified) {
      autoLoginInProgress = false
      return
    }

    await new Promise((resolve) => setTimeout(resolve, 300))

    const loginBtn = document.querySelector('button#login-submit')
    if (loginBtn) {
      console.log('[AutoLogin] Clicking login button.')
      GM_setValue('autoLoginArmed', false)
      loginBtn.click()
    } else {
      console.warn('[AutoLogin] Login button not found.')
      autoLoginInProgress = false
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', autoLogin)
  } else {
    autoLogin()
  }

  // --- CHAT ASSISTANT (CONTROLLER) ---
  class ChatAssistant {
    constructor() {
      this.ui = new UIManager()
      this.msg = new MessageManager()
      this.sessionId = null
      this.socket = null
      this.lastFingerprint = ''
      this.messageObserver = null
      this._sessionValidityTimer = null

      this.pendingSessionQueue = new Map()

      this.initSocket()
      this.initSessionObserver()
    }

    emitOrQueue(eventName, data) {
      if (!this.socket.connected) {
        if (eventName === 'submit_session') {
          this.pendingSessionQueue.set(
            data.sessionId,
            JSON.parse(JSON.stringify(data)),
          )
          this.ui.updateQueueIndicator(this.pendingSessionQueue.size)
        }
        // update_session and remove_session while offline: silently drop (submit_session
        // on reconnect will re-sync the full state, so these intermediate events are safe to skip)
        return
      }
      this.socket.emit(eventName, data)
    }

    _emitMediaAvailability() {
      const available = !!Array.from(document.querySelectorAll('button')).find(
        (b) => b.textContent.replace(/\s+/g, ' ').trim() === 'Media Center',
      )
      this.socket.emit('media_availability', {
        browserId: this.ui.browserIdentifier,
        available,
      })
    }

    flushPendingQueue() {
      if (this.pendingSessionQueue.size === 0) return
      const liveId = this.getLiveSessionId()
      for (const [, data] of this.pendingSessionQueue) {
        if (data.sessionId === liveId) this.socket.emit('submit_session', data)
      }
      this.pendingSessionQueue.clear()
      this.ui.updateQueueIndicator(0)
    }

    getLiveSessionId() {
      const el = document.querySelector(CONFIG.ui.sessionSelector)
      return el ? el.textContent.split('#')[1]?.trim() : null
    }

    initSocket() {
      this.socket = io(CONFIG.server.url, {
        query: { browserId: this.ui.browserIdentifier },
        reconnectionDelay: 1000,
      })

      this.socket.on('connect', () => {
        console.log('[ChatAssistant] Connected')
        this.ui.setConnectionStatus(true)
        this._emitMediaAvailability()

        if (this.pendingSessionQueue.size > 0) {
          // Socket was disconnected while a new session arrived — flush queued sends.
          this.flushPendingQueue()
        } else if (this.sessionId) {
          const liveId = this.getLiveSessionId()
          if (liveId !== this.sessionId) {
            const oldId = this.sessionId
            console.log(
              `[ChatAssistant] Session changed during reconnect: ${oldId} -> ${liveId}`,
            )
            this.emitOrQueue('remove_session', {
              action: 'remove',
              browserId: this.ui.browserIdentifier,
              sessionId: oldId,
            })
            this.lastFingerprint = ''
            this.sessionId = liveId
            if (liveId) this.handleNewSession()
            return
          }
          console.log(
            '[ChatAssistant] Reconnected with active session — re-syncing.',
          )
          this.syncSessionData('submit_session')
        }
      })

      // GUI asks the browser to re-check whether Media Center is available
      this.socket.on('check_media_availability', () => {
        this._emitMediaAvailability()
      })

      this.socket.on('disconnect', () => this.ui.setConnectionStatus(false))

      this.socket.on('session_updated', (data) => {
        // This is the event triggered when you press "Send" on the Python GUI
        if (data.sessionId === this.sessionId && data.action === 'send') {
          if (data.profileInfo) this.fillProfileFields(data.profileInfo)
          this.automateResponse(data.response)
        }
      })

      this.socket.on('trigger_relogin', (data) => {
        if (data?.reason !== 'duplicate_session') return
        console.log('[AutoLogin] Duplicate session detected by server — triggering re-login.')
        this._triggerLogoutAndRelogin()
      })

      // --- MEDIA CENTER HANDLERS ---

      // 1.1 — Open Media Center and scrape folder list
      this.socket.on('open_media_center', async () => {
        try {
          const mediaBtn = Array.from(document.querySelectorAll('button')).find(
            (b) => b.textContent.replace(/\s+/g, ' ').trim() === 'Media Center',
          )
          if (!mediaBtn) {
            this.socket.emit('media_folders_data', {
              browserId: this.ui.browserIdentifier,
              folders: [],
              error: 'Media Center button not found in DOM',
            })
            return
          }
          mediaBtn.click()

          // 1.2 — Wait for folder grid to appear (MutationObserver-based, handles slow loads)
          await waitForElement('.card.card-clickable', 30000)

          const folderCards = Array.from(
            document.querySelectorAll('.card.card-clickable'),
          )
          const folders = folderCards.map((card, index) => {
            const idText =
              card.querySelector('.card-body')?.textContent?.trim() ||
              `folder_${index}`
            return { index, id: idText }
          })

          this.socket.emit('media_folders_data', {
            browserId: this.ui.browserIdentifier,
            folders,
          })
        } catch (err) {
          console.error('[Media] open_media_center error:', err.message)
          this.socket.emit('media_folders_data', {
            browserId: this.ui.browserIdentifier,
            folders: [],
            error: err.message,
          })
        }
      })

      // 1.2 — Scrape folder list from already-open Media Center (no button click)
      //        Used by the GUI retry button to avoid opening a second modal.
      this.socket.on('scrape_media_folders', () => {
        try {
          const folderCards = Array.from(
            document.querySelectorAll('.card.card-clickable'),
          )
          if (!folderCards.length) {
            this.socket.emit('media_folders_data', {
              browserId: this.ui.browserIdentifier,
              folders: [],
              error: 'No folder cards found. Is the Media Center still open?',
            })
            return
          }
          const folders = folderCards.map((card, index) => {
            const idText =
              card.querySelector('.card-body')?.textContent?.trim() ||
              `folder_${index}`
            return { index, id: idText }
          })
          this.socket.emit('media_folders_data', {
            browserId: this.ui.browserIdentifier,
            folders,
          })
        } catch (err) {
          console.error('[Media] scrape_media_folders error:', err.message)
          this.socket.emit('media_folders_data', {
            browserId: this.ui.browserIdentifier,
            folders: [],
            error: err.message,
          })
        }
      })

      // 1.3 — Open a specific folder and scrape its image list
      this.socket.on('open_media_folder', async (data) => {
        try {
          const { folderIndex } = data

          const folderCards = document.querySelectorAll('.card.card-clickable')
          const targetCard = folderCards[folderIndex]
          if (!targetCard) {
            this.socket.emit('media_images_data', {
              browserId: this.ui.browserIdentifier,
              images: [],
              error: `Folder card at index ${folderIndex} not found`,
            })
            return
          }

          // Helper: collect only image-grid cards (never folder cards)
          const getImageCards = () =>
            Array.from(document.querySelectorAll('.card')).filter(
              (card) =>
                card.querySelector('.card-img-top') &&
                card.querySelector('.btn'),
            )

          targetCard.click()

          // Wait for Angular to render the initial grid before we start scrolling
          await new Promise((resolve) => setTimeout(resolve, 600))

          // 1.4 — Force lazy-load by scrolling every card into view.
          // Angular's ng-lazyload-image only sets src once the element enters
          // the viewport via IntersectionObserver. Cards below the fold will
          // never get a real src unless we trigger that observer manually.
          // We scroll top→bottom so cards are triggered in order, then return
          // to the top — avoids top cards being skipped when we scroll back up.
          const triggerLazyLoad = async () => {
            const cards = getImageCards()
            for (const card of cards) {
              card.scrollIntoView({ block: 'nearest', inline: 'nearest' })
              // Small yield per card so the IntersectionObserver can fire
              // before we move on to the next one
              await new Promise((resolve) => setTimeout(resolve, 30))
            }
          }

          await triggerLazyLoad()

          // Pause so the browser can fire the IntersectionObserver
          // callbacks and begin loading the newly-visible images
          await new Promise((resolve) => setTimeout(resolve, 400))

          // Poll until every image card in the grid has a real http src.
          // MutationObserver + attributeFilter['src'] is unreliable here because
          // Angular may set .src via property assignment (not setAttribute), which
          // never fires an attribute mutation. Polling reads the live DOM directly.
          await new Promise((resolve, reject) => {
            const TIMEOUT_MS = 30000
            const POLL_INTERVAL_MS = 100
            const deadline = Date.now() + TIMEOUT_MS

            const poll = () => {
              const cards = getImageCards()
              const allReady =
                cards.length > 0 &&
                cards.every((c) =>
                  (c.querySelector('.card-img-top')?.src ?? '').startsWith(
                    'http',
                  ),
                )

              if (allReady) {
                resolve()
                return
              }

              if (Date.now() >= deadline) {
                // Timed out — emit whatever we have rather than leaving the GUI hanging
                console.warn(
                  '[Media] Timed out waiting for all image srcs; emitting partial results',
                )
                resolve()
                return
              }

              // Re-trigger lazy load on each poll tick in case new cards
              // rendered after the initial scroll pass (e.g. virtual scrolling)
              triggerLazyLoad().then(() => setTimeout(poll, POLL_INTERVAL_MS))
              return
            }

            poll()
          })

          // Scroll back to top so the dialog looks natural
          const firstCard = getImageCards()[0]
          if (firstCard) firstCard.scrollIntoView({ block: 'start' })

          // Collect every .card that has a .card-img-top (image cards only)
          const imageCards = getImageCards()

          const images = imageCards.map((card, index) => {
            const alreadySent = card.classList.contains('bg-grayed')
            const src = card.querySelector('.card-img-top')?.src || ''
            const label =
              card
                .querySelector('.card-body p')
                ?.textContent?.trim()
                .replace(/^\s*\n/, '') || `image_${index}`
            return { index, label, src, alreadySent }
          })

          this.socket.emit('media_images_data', {
            browserId: this.ui.browserIdentifier,
            images,
          })
        } catch (err) {
          console.error('[Media] open_media_folder error:', err.message)
          this.socket.emit('media_images_data', {
            browserId: this.ui.browserIdentifier,
            images: [],
            error: err.message,
          })
        }
      })

      // 1.5 — Click the Select button on a specific image
      this.socket.on('select_media_image', (data) => {
        try {
          const { imageIndex } = data
          const imageCards = Array.from(
            document.querySelectorAll('.card'),
          ).filter(
            (card) =>
              card.querySelector('.card-img-top') && card.querySelector('.btn'),
          )
          const targetCard = imageCards[imageIndex]
          if (!targetCard) return
          targetCard.querySelector('.btn').click()

          // After clicking, wait briefly for Angular to attach the image to
          // the chat input, then scrape and report the src back to the GUI.
          setTimeout(() => this._scrapeAndReportAttachedImage(), 600)
        } catch (err) {
          console.error('[Media] select_media_image error:', err.message)
        }
      })

      // 1.7 — GUI asks the browser to remove the currently attached image
      this.socket.on('remove_media_image', () => {
        try {
          const attachedRow = document.querySelector(
            '.row.ng-star-inserted[style*="margin-top: -50px"]',
          )
          if (!attachedRow) {
            console.warn(
              '[Media] remove_media_image: attached image row not found',
            )
            return
          }
          const closeBtn = attachedRow.querySelector('button.btn-danger')
          if (closeBtn) {
            closeBtn.click()
            console.log('[Media] remove_media_image: close button clicked')
          } else {
            console.warn('[Media] remove_media_image: close button not found')
          }
        } catch (err) {
          console.error('[Media] remove_media_image error:', err.message)
        }
      })

      // 1.6 — Close the Media Center (emitted by the GUI when the dialog is cancelled)
      this.socket.on('close_media_center', () => {
        try {
          // Find the Close button by its material-icons child text content
          const closeBtn = Array.from(
            document.querySelectorAll('button.btn-primary.btn-outline-dark'),
          ).find(
            (b) =>
              b.querySelector('i.material-icons')?.textContent?.trim() ===
              'close',
          )
          if (closeBtn) {
            closeBtn.click()
          } else {
            console.warn(
              '[Media] close_media_center: Close button not found in DOM',
            )
          }
        } catch (err) {
          console.error('[Media] close_media_center error:', err.message)
        }
      })

      // --- PANIC ROOM HANDLERS ---

      // PR.1 — GUI asks us to click the browser's Panic Room button and open the modal
      this.socket.on('open_panic_room', async () => {
        try {
          const panicBtn = Array.from(document.querySelectorAll('button')).find(
            (b) =>
              b.textContent
                .replace(/\s+/g, ' ')
                .trim()
                .toLowerCase()
                .includes('panic'),
          )
          if (!panicBtn) {
            console.error('[PanicRoom] Panic Room button not found in DOM')
            return
          }

          panicBtn.click()

          // PR.2 — Wait for the modal's <select> to appear (signals the modal is open)
          await waitForElement('select.form-control', 10000)

          // Confirm to the GUI that the modal is ready
          this.socket.emit('panic_room_modal_ready', {
            browserId: this.ui.browserIdentifier,
          })
        } catch (err) {
          console.error('[PanicRoom] open_panic_room error:', err.message)
        }
      })

      // PR.3 — GUI user changed the dropdown; mirror it to the browser <select>
      this.socket.on('select_panic_reason', (data) => {
        try {
          const { reason } = data
          const select = document.querySelector('select.form-control')
          if (!select) return
          const match = Array.from(select.options).find(
            (o) => o.value === reason,
          )
          if (!match) return
          select.selectedIndex = match.index
          select.dispatchEvent(new Event('change', { bubbles: true }))
          select.dispatchEvent(new Event('input', { bubbles: true }))
        } catch (err) {
          console.error('[PanicRoom] select_panic_reason error:', err.message)
        }
      })

      // PR.4 — GUI user clicked Yes; mirror it to the browser Yes button
      this.socket.on('confirm_panic_room', () => {
        try {
          const footer = document.querySelector('.modal-footer')
          if (!footer) return
          const yesBtn = footer.querySelector('button.btn-primary')
          if (yesBtn) yesBtn.click()
        } catch (err) {
          console.error('[PanicRoom] confirm_panic_room error:', err.message)
        }
      })

      // PR.5 — GUI user clicked No; mirror it to the browser No button
      this.socket.on('cancel_panic_room', () => {
        try {
          const footer = document.querySelector('.modal-footer')
          if (!footer) return
          const noBtn = footer.querySelector('button.btn-danger')
          if (noBtn) noBtn.click()
        } catch (err) {
          console.error('[PanicRoom] cancel_panic_room error:', err.message)
        }
      })
    }

    initSessionObserver() {
      const checkSession = () => {
        const newId = this.getLiveSessionId()

        const liveMessages = this.msg.getMessages()
        const liveFingerprint = liveMessages
          .map((m) => (m.text || '') + m.sender + (m.imageUrl || ''))
          .join('|')

        const idChanged = newId !== this.sessionId
        const sameIdNewSession =
          newId && newId === this.sessionId && liveFingerprint !== this.lastFingerprint && liveFingerprint !== ''

        if (idChanged || sameIdNewSession) {
          const oldId = this.sessionId

          if (idChanged) {
            console.log(
              `[ChatAssistant] Session changed: ${oldId} -> ${newId}`,
            )
          } else {
            console.log(
              `[ChatAssistant] Same session ID reused with new messages: ${newId}`,
            )
          }

          if (oldId) {
            this.emitOrQueue('remove_session', {
              action: 'remove',
              browserId: this.ui.browserIdentifier,
              sessionId: oldId,
            })
            this.lastFingerprint = ''
          }

          this.sessionId = newId
          if (newId) {
            const area = document.querySelector(CONFIG.ui.textareaSelector)
            if (area) area.value = ''
            this.handleNewSession()
            this.initMessageSync()
            this._startSessionValidityWatch(newId)
          }
        }
      }

      const observer = new MutationObserver(debounce(checkSession, 300))
      observer.observe(document.body, { childList: true, subtree: true })
      checkSession()
    }

    initMessageSync() {
      if (this.messageObserver) {
        this.messageObserver.disconnect()
        this.messageObserver = null
      }

      waitForElement(CONFIG.ui.timelineSelector)
        .then((el) => {
          console.log('[ChatAssistant] Timeline found, attaching observer.')
          this.messageObserver = new MutationObserver(
            debounce(() => {
              if (this.sessionId) this.syncSessionData()
            }, 100),
          )
          this.messageObserver.observe(el, { childList: true, subtree: true })
        })
        .catch((e) => {})
    }

    _hasElapsedTimeElement() {
      const headers = Array.from(
        document.querySelectorAll(
          'h5.card-header.card-header-primary, h5.card-header.card-header-danger',
        ),
      )
      return headers.some((header) =>
        Array.from(header.querySelectorAll('span.ng-star-inserted')).some((span) =>
          span.textContent.includes('Elapsed time:'),
        ),
      )
    }

    _triggerLogoutAndRelogin() {
      console.log('[AutoLogin] Going back and triggering auto-login...')
      if (this._sessionValidityTimer) {
        clearTimeout(this._sessionValidityTimer)
        this._sessionValidityTimer = null
      }
      GM_setValue('autoLoginArmed', true)
      history.back()
      setTimeout(() => autoLogin(), 1500)
    }

    _startSessionValidityWatch(expectedSessionId) {
      if (this._sessionValidityTimer) {
        clearTimeout(this._sessionValidityTimer)
        this._sessionValidityTimer = null
      }

      this._sessionValidityTimer = setTimeout(() => {
        this._sessionValidityTimer = null
        if (this.sessionId !== expectedSessionId) return
        if (this.getLiveSessionId() !== expectedSessionId) return

        if (!this._hasElapsedTimeElement()) {
          console.warn(
            '[AutoLogin] Elapsed time element missing — session invalid. Triggering re-login.',
          )
          this._triggerLogoutAndRelogin()
        } else {
          console.log('[AutoLogin] Elapsed time element found — session is valid.')
        }
      }, 2000)
    }

    async handleNewSession() {
      const capturedId = this.sessionId
      await new Promise((resolve) => setTimeout(resolve, 150))
      if (!capturedId || this.sessionId !== capturedId) return
      if (this.getLiveSessionId() !== capturedId) return
      this.syncSessionData('submit_session')
    }

    async syncSessionData(eventName = 'update_session') {
      if (!this.sessionId) return

      const messages = this.msg.getMessages()
      const currentFingerprint = messages
        .map((m) => (m.text || '') + m.sender + (m.imageUrl || ''))
        .join('|')

      // Don't resend if nothing changed (Bandwidth Optimization)
      if (
        currentFingerprint === this.lastFingerprint &&
        eventName === 'update_session'
      ) {
        return
      }
      this.lastFingerprint = currentFingerprint

      const data = {
        browserId: this.ui.browserIdentifier,
        sessionId: this.sessionId,
        messages: messages,
        profileInfo: this.msg.getProfileInfo(),
        agentEnNumber: this.msg.getAgentEnNumber(),
        serviceText:
          document.querySelector('.alert.alert-light p')?.textContent?.trim() ||
          '',
        customerRating: this.getCustomerRating(),
      }

      this.emitOrQueue(eventName, data)
    }

    getCustomerRating() {
      const ratingBadge = document.querySelector(
        '.card-footer bar-rating + .badge',
      )
      return ratingBadge ? parseInt(ratingBadge.textContent.trim(), 10) || 0 : 0
    }

    _scrapeAndReportAttachedImage() {
      try {
        const attachedRow = document.querySelector(
          '.row.ng-star-inserted[style*="margin-top: -50px"]',
        )
        if (!attachedRow) {
          // Angular may not have rendered it yet — retry once more after a
          // further 400 ms before giving up silently.
          setTimeout(() => {
            const retryRow = document.querySelector(
              '.row.ng-star-inserted[style*="margin-top: -50px"]',
            )
            if (!retryRow) return
            const img = retryRow.querySelector('.card-img-top')
            if (img && img.src) {
              this.socket.emit('media_image_attached', {
                browserId: this.ui.browserIdentifier,
                src: img.src,
              })
            }
          }, 400)
          return
        }

        const img = attachedRow.querySelector('.card-img-top')
        if (img && img.src) {
          this.socket.emit('media_image_attached', {
            browserId: this.ui.browserIdentifier,
            src: img.src,
          })
          console.log('[Media] Reported attached image to GUI:', img.src)
        }
      } catch (err) {
        console.error(
          '[Media] _scrapeAndReportAttachedImage error:',
          err.message,
        )
      }
    }

    fillProfileFields(info) {
      const mapping = {
        'customer-custom': info.customerCustom,
        'customer-description': info.customerDescription,
        'moderator-custom': info.moderatorCustom,
        'moderator-description': info.moderatorDescription,
      }

      Object.entries(mapping).forEach(([id, val]) => {
        if (val === undefined) return
        const el = document.getElementById(id)
        if (el && el.value !== val) setNativeValue(el, val)
      })
    }

    async automateResponse(text) {
      try {
        const textarea = await waitForElement(CONFIG.ui.textareaSelector)

        // 1. Set value using the fixed robust setter
        setNativeValue(textarea, text + ' ')

        // 2. Extra event triggers to force Angular validation
        textarea.dispatchEvent(new Event('input', { bubbles: true }))
        // This keyup event is often required by older Angular apps to mark the field "dirty"
        textarea.dispatchEvent(
          new KeyboardEvent('keyup', { bubbles: true, key: ' ' }),
        )

        textarea.focus()

        // 3. SAFETY DELAY: Wait 500ms for Angular validation to run and render any alerts
        // This prevents clicking "Send" before the "Duplicate Message" error appears.
        setTimeout(() => {
          this.waitForSafetyAndSubmit()
        }, 500)
      } catch (err) {
        this.ui.showNotification('Could not insert text: ' + err.message)
        console.error(err)
      }
    }

    waitForSafetyAndSubmit(attempts = 0) {
      // 1. Check for Errors FIRST
      // We check this immediately. If the alert is there, we ABORT.
      const errorAlert = document.querySelector('.alert.alert-danger')
      if (errorAlert) {
        this.ui.showNotification(
          'Error/Duplicate detected! Aborting auto-submit.',
        )
        return
      }

      // 2. Check Button State
      const btn = document.querySelector('button[type="submit"].btn-primary')

      if (btn && !btn.disabled) {
        btn.click()
        this.ui.showNotification('Auto-submitted!')
        return
      }

      // 3. Keep Waiting
      if (attempts < 20) {
        setTimeout(() => this.waitForSafetyAndSubmit(attempts + 1), 250)
      } else {
        this.ui.showNotification(
          'Submit button stuck disabled (Validation failed?)',
        )
      }
    }
  }

  const assistant = new ChatAssistant()
  window.chatAssistant = assistant
})()