Last active
December 5, 2025 00:15
-
-
Save rexxar31/98436c16401b60af4e0b2723c72071de to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Chat Script | |
| // @namespace test | |
| // @version 2.2.6 | |
| // @description For CW use only | |
| // @match https://agents.moderationinterface.com/* | |
| // @require https://cdn.socket.io/4.7.5/socket.io.min.js | |
| // @updateURL https://gist.githubusercontent.com/rexxar31/98436c16401b60af4e0b2723c72071de/raw/prodFront_final.user.js | |
| // @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' | |
| } | |
| 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', | |
| ), | |
| ) | |
| // 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() || '' | |
| if (!text || 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' | |
| acc.push({ | |
| text: isAuto ? text.replace('Automated text:', '').trim() : text, | |
| sender, | |
| }) | |
| 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'), | |
| } | |
| } | |
| 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 | |
| const match = text.match(/\[?([A-Z]{2})[-_]EN\]?/) | |
| if (match) return match[1] === 'IR' ? 'IE' : match[1] | |
| if (text.includes('USA-EN')) return 'US' | |
| return null | |
| } | |
| } | |
| // --- CHAT ASSISTANT (CONTROLLER) --- | |
| class ChatAssistant { | |
| constructor() { | |
| this.ui = new UIManager() | |
| this.msg = new MessageManager() | |
| this.sessionId = null | |
| this.socket = null | |
| this.lastFingerprint = '' | |
| this.initSocket() | |
| this.initSessionObserver() | |
| } | |
| 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.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) | |
| } | |
| }) | |
| } | |
| initSessionObserver() { | |
| const checkSession = () => { | |
| const el = document.querySelector(CONFIG.ui.sessionSelector) | |
| const newId = el ? el.textContent.split('#')[1]?.trim() : null | |
| if (newId !== this.sessionId) { | |
| console.log( | |
| `[ChatAssistant] Session changed: ${this.sessionId} -> ${newId}`, | |
| ) | |
| if (this.sessionId && !newId) { | |
| this.socket.emit('update_session', { | |
| action: 'remove', | |
| browserId: this.ui.browserIdentifier, | |
| sessionId: this.sessionId, | |
| }) | |
| this.lastFingerprint = '' | |
| } | |
| this.sessionId = newId | |
| if (newId) { | |
| const area = document.querySelector(CONFIG.ui.textareaSelector) | |
| if (area) area.value = '' | |
| this.handleNewSession() | |
| this.initMessageSync() | |
| } | |
| } | |
| } | |
| const observer = new MutationObserver(debounce(checkSession, 50)) | |
| observer.observe(document.body, { childList: true, subtree: true }) | |
| checkSession() | |
| } | |
| initMessageSync() { | |
| waitForElement(CONFIG.ui.timelineSelector) | |
| .then((el) => { | |
| console.log('[ChatAssistant] Timeline found, attaching observer.') | |
| const observer = new MutationObserver( | |
| debounce(() => { | |
| if (this.sessionId) this.syncSessionData() | |
| }, 100), | |
| ) | |
| observer.observe(el, { childList: true, subtree: true }) | |
| }) | |
| .catch((e) => {}) | |
| } | |
| async handleNewSession() { | |
| 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) | |
| .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.socket.emit(eventName, data) | |
| } | |
| getCustomerRating() { | |
| const ratingBadge = document.querySelector( | |
| '.card-footer bar-rating + .badge', | |
| ) | |
| return ratingBadge ? parseInt(ratingBadge.textContent.trim(), 10) || 0 : 0 | |
| } | |
| 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 | |
| })() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment