Chat Script

For CW use only

2026-05-14 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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
})()