For CW use only
// ==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
})()