您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enables a filter to be turned on to your chat which prevents messages from appearing which contain commands for common bots (e.g. "!" and ">"), responses from said bots, specified keywords/phrases, specified usernames, or emote messages. Intercepts messages before rendering.
// ==UserScript== // @name Fishtank.live Chat Bot Silencer & Extended Mute Tools // @namespace http://tampermonkey.net/ // @version 1.0.0 // @description Enables a filter to be turned on to your chat which prevents messages from appearing which contain commands for common bots (e.g. "!" and ">"), responses from said bots, specified keywords/phrases, specified usernames, or emote messages. Intercepts messages before rendering. // @author @c // @match https://fishtank.live/* // @match https://www.fishtank.live/* // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @run-at document-start // ==/UserScript== (function() { 'use strict'; const SCRIPT_PREFIX = 'fte_minimal_'; const DEBUG_MODE = true; // Set to true if wanting to enable console logging for debugging. // --- DOM Selectors & IDs --- const CHAT_MESSAGES_CONTAINER_ID = 'chat-messages'; // ID of the main chat message container. const CHAT_MESSAGE_WRAPPER_SELECTOR = 'div[class*="chat-message-default_chat-message-default__"]'; // Selector for standard chat message wrappers. const EMOTE_MESSAGE_WRAPPER_SELECTOR = 'div[class*="chat-message-emote_chat-message-emote__"]'; // Selector for emote-only/system message wrappers. const ALL_MESSAGE_SELECTORS_QUERY = `${CHAT_MESSAGE_WRAPPER_SELECTOR}, ${EMOTE_MESSAGE_WRAPPER_SELECTOR}`; // Combined selector for all message types. const MESSAGE_TEXT_SELECTOR = 'span[class*="chat-message-default_message__"]'; // Selector for the text content of a message. const USERNAME_SELECTOR_DEFAULT = 'span[class*="chat-message-default_user__"]'; // Selector for usernames in standard messages. const USERNAME_SELECTOR_EMOTE = 'span[class*="chat-message-emote_user__"]'; // Selector for usernames in emote messages. const CHAT_HEADER_SELECTOR = 'div[class*="chat_header__"]'; // Selector for the chat header, where controls are added. // --- SVG Icons for UI elements --- const SVG_ICON_USER_FILTER_CONTROL = `<svg width="15" height="15" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="#D3D3D3"/><circle cx="12" cy="12" r="10" stroke="red" stroke-width="1.8" fill="none"/><line x1="4.93" y1="19.07" x2="19.07" y2="4.93" stroke="red" stroke-width="1.8"/></svg>`; const SVG_ICON_SETTINGS_GEAR = `<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd"><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0z"></path></svg>`; const SVG_ICON_ADD_PLUS = `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"></path></svg>`; const SVG_ICON_TRASH = `<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zm2.46-7.12l1.41-1.41L12 12.59l2.12-2.12 1.41 1.41L13.41 14l2.12 2.12-1.41 1.41L12 15.41l-2.12 2.12-1.41-1.41L10.59 14l-2.13-2.12zM15.5 4l-1-1h-5l-1 1H5v2h14V4z"></path></svg>`; const SVG_ICON_HELP = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>`; // --- Bot Specific Identifiers --- const GRIZZBOT_USER_ID = "3640961d-d3d2-434e-9e16-e40a9b72fb7f"; // Profile ID for Grizzbot. const LEXXBOT_USER_ID = "4585f683-4656-4468-8918-b9b5ce471d12"; // Profile ID for Lexxbot. const GRIZZBOT_USERNAME = "grizzbot"; // Username for Grizzbot. const LEXXBOT_USERNAME = "lexxbot"; // Username for Lexxbot. let settings = {}; // Holds the current user settings for the script. const defaultSettings = { // Default settings, used if no saved settings are found. masterFilterActive: true, commandFilterEnabled: false, usernameFilterEnabled: true, blockedUsernames: [], keywordFilterEnabled: true, blockedKeywords: [], muteGrizzbot: false, muteLexxbot: false, muteEmotes: false }; // References to UI elements created by the script. let masterFilterBtn, settingsBtn, settingsPanelEl, chatObserver, controlsContainer; // Conditional logging function, only logs if DEBUG_MODE is true. const log = (...args) => DEBUG_MODE && console.log('[FTE ChatSilencer]', ...args); /** * Loads settings from browser storage (via GM_getValue). * Merges stored settings with defaults to ensure all settings are present. */ function loadSettings() { const storedSettingsRaw = GM_getValue(SCRIPT_PREFIX + 'settings', null); let storedSettingsParsed = storedSettingsRaw ? JSON.parse(storedSettingsRaw) : {}; // Migration: remove obsolete settings if they exist from older versions. delete storedSettingsParsed.userBlocklistEnabled; delete storedSettingsParsed.blockedUsers; // Merge default settings with stored settings, ensuring arrays are properly initialized. settings = { ...defaultSettings, ...storedSettingsParsed, blockedUsernames: Array.isArray(storedSettingsParsed.blockedUsernames) ? storedSettingsParsed.blockedUsernames : defaultSettings.blockedUsernames, blockedKeywords: Array.isArray(storedSettingsParsed.blockedKeywords) ? storedSettingsParsed.blockedKeywords : defaultSettings.blockedKeywords }; log("Settings loaded:", JSON.parse(JSON.stringify(settings))); } /** * Saves the current settings to browser storage (via GM_setValue). */ function saveSettings() { // Only save relevant settings to avoid storing unnecessary data. const settingsToSave = { masterFilterActive: settings.masterFilterActive, commandFilterEnabled: settings.commandFilterEnabled, usernameFilterEnabled: settings.usernameFilterEnabled, blockedUsernames: settings.blockedUsernames, keywordFilterEnabled: settings.keywordFilterEnabled, blockedKeywords: settings.blockedKeywords, muteGrizzbot: settings.muteGrizzbot, muteLexxbot: settings.muteLexxbot, muteEmotes: settings.muteEmotes }; GM_setValue(SCRIPT_PREFIX + 'settings', JSON.stringify(settingsToSave)); log("Settings saved."); } loadSettings(); // Load settings when the script starts. // Reasons for blocking a message, used for logging. const BLOCK_REASONS = { GRIZZBOT_ID: 'Muting grizzbot (User ID)', LEXXBOT_ID: 'Muting lexxbot (User ID)', GRIZZBOT_NAME: 'Muting grizzbot (Username)', LEXXBOT_NAME: 'Muting lexxbot (Username)', SYSTEM_EMOTE: 'Muting system emote message (e.g. /roll, /fall)', CUSTOM_EMOTE_ONLY: 'Muting message composed entirely of custom emotes', USERNAME_FILTER: 'Filtering username', COMMAND_FILTER: 'Filtering command', KEYWORD_FILTER: 'Filtering keyword in message' }; /** * Determines if a message should be blocked based on current filter settings. * @param {object} details - An object containing message details (userId, username, text, emotes, isSystemEmote). * @param {object} currentSettings - The current filter settings. * @returns {object} An object with `blocked: true` and a `reason` if the message should be blocked, otherwise `blocked: false`. */ function getBlockReason(details, currentSettings) { const { userId, username, text, emotes, isSystemEmote } = details; const usernameLower = username ? username.toLowerCase() : null; // For case-insensitive username matching. const textTrimmedStart = text ? text.trimStart() : null; // For checking command prefixes. const textLower = text ? text.toLowerCase() : null; // For case-insensitive keyword matching. // --- Bot Muting Filters --- if (userId) { if (userId === GRIZZBOT_USER_ID && currentSettings.muteGrizzbot) return { blocked: true, reason: BLOCK_REASONS.GRIZZBOT_ID, data: GRIZZBOT_USERNAME }; if (userId === LEXXBOT_USER_ID && currentSettings.muteLexxbot) return { blocked: true, reason: BLOCK_REASONS.LEXXBOT_ID, data: LEXXBOT_USERNAME }; } if (usernameLower) { if (usernameLower === GRIZZBOT_USERNAME && currentSettings.muteGrizzbot) return { blocked: true, reason: BLOCK_REASONS.GRIZZBOT_NAME, data: GRIZZBOT_USERNAME }; if (usernameLower === LEXXBOT_USERNAME && currentSettings.muteLexxbot) return { blocked: true, reason: BLOCK_REASONS.LEXXBOT_NAME, data: LEXXBOT_USERNAME }; } // --- Emote and Slash Command Muting Filter (if muteEmotes is enabled) --- // This section is modified to prioritize pre-renderable checks. if (currentSettings.muteEmotes) { // 1. Filter slash commands (e.g., /roll, /me, /shrug). // This allows pre-filtering based on text content from WebSocket. if (textTrimmedStart && textTrimmedStart.startsWith('/')) { return { blocked: true, reason: BLOCK_REASONS.SYSTEM_EMOTE, data: `Slash command: ${text.substring(0, 20).trim()}${username ? ` by ${username}` : ''}` }; } // 2. Filter messages composed entirely of custom emotes. // Relies on `details.emotes` from WebSocket payload for pre-filtering. if (text && emotes && Object.keys(emotes).length > 0) { const trimmedTextContent = text.trim(); if (trimmedTextContent) { // Ensure text is not just whitespace const messageTokens = trimmedTextContent.split(/\s+/); // Ensure there are tokens and all tokens are keys in the `emotes` map. if (messageTokens.length > 0 && messageTokens.every(token => emotes.hasOwnProperty(token))) { return { blocked: true, reason: BLOCK_REASONS.CUSTOM_EMOTE_ONLY, data: `Custom emotes: ${text.substring(0, 30).trim()}...` }; } } } // 3. Fallback for DOM-identified system emote messages (if not a slash command and not purely custom emotes). // `details.isSystemEmote` is `true` only if called from `processMessageNode` (DOM filter path). // For pre-filtering (WebSocket), `details.isSystemEmote` is `false`. if (isSystemEmote) { // This path is primarily for the DOM filter to catch messages that have the special emote class // but weren't identified by the text-based rules above. return { blocked: true, reason: BLOCK_REASONS.SYSTEM_EMOTE, data: `Marked emote message ${username ? `by ${username}` : '(system)'}${text ? ` (${text.substring(0,20).trim()}...)` : ''}` }; } } // --- Other Filters --- // Check against blocked usernames list. if (currentSettings.usernameFilterEnabled && currentSettings.blockedUsernames.length > 0 && usernameLower) { if (currentSettings.blockedUsernames.some(bn => usernameLower.includes(bn.toLowerCase()))) return { blocked: true, reason: BLOCK_REASONS.USERNAME_FILTER, data: username }; } // Check for bot command prefixes. if (currentSettings.commandFilterEnabled && textTrimmedStart && (textTrimmedStart.startsWith('!') || textTrimmedStart.startsWith('>'))) return { blocked: true, reason: BLOCK_REASONS.COMMAND_FILTER, data: text.substring(0, 20).trim() + '...' }; // Check against blocked keywords list. if (currentSettings.keywordFilterEnabled && currentSettings.blockedKeywords.length > 0 && textLower) { if (currentSettings.blockedKeywords.some(kw => textLower.includes(kw.toLowerCase()))) return { blocked: true, reason: BLOCK_REASONS.KEYWORD_FILTER, data: text.substring(0,30).trim() + '...' }; } return { blocked: false }; // If no rules match, don't block. } /** * Formats a log message for a blocked item. * @param {string} logContext - Context like 'PRE-FILTER' or 'DOM'. * @param {object} filterResult - The result from getBlockReason. * @returns {string} Formatted log string. */ function formatBlockReasonLog(logContext, filterResult) { return `[${logContext}] ${filterResult.reason}${filterResult.data ? `: ${filterResult.data}` : ''}`; } // --- WebSocket Interception (Pre-filtering) --- const ORIGINAL_WEBSOCKET = window.WebSocket; // Store the original WebSocket constructor. const CHAT_WEBSOCKET_URL_PATTERN = /wss:\/\/chat\.fishtank\.live\/ws(?:\?.*)?$/; // Regex to identify the Fishtank chat WebSocket. /** * Parses raw WebSocket message data into a structured object for filtering. * @param {string} rawData - The raw message data from WebSocket. * @returns {object|null} Parsed message details or null if not a parsable/relevant message. */ function parseWebSocketMessage(rawData) { try { const parsed = JSON.parse(rawData); // Check if it's a 'MESSAGE_CREATE' type with expected payload. if (parsed.type === 'MESSAGE_CREATE' && parsed.payload && typeof parsed.payload.user_id !== 'undefined' && typeof parsed.payload.message !== 'undefined' && typeof parsed.payload.username !== 'undefined') { return { // Extract relevant fields. userId: parsed.payload.user_id, text: parsed.payload.message, username: parsed.payload.username, emotes: parsed.payload.emotes || {}, // Emotes mapping, if any. isSystemEmote: false // Default to false for pre-filtering; DOM filter sets this based on class. }; } } catch (e) { DEBUG_MODE && console.warn('[FTE ChatSilencer]', '[PRE-FILTER] Failed to parse WebSocket message or incomplete payload. Error:', e, 'Raw Data:', rawData); } return null; // Return null if parsing fails or message isn't relevant. } /** * Checks if a parsed WebSocket message should be pre-filtered (blocked before rendering). * @param {object} msgDataFromParser - Parsed message data from `parseWebSocketMessage`. * @returns {boolean} True if the message should be blocked, false otherwise. */ function shouldPreFilterMessage(msgDataFromParser) { if (!msgDataFromParser || !settings.masterFilterActive) return false; // Don't filter if no data or master filter is off. const filterResult = getBlockReason(msgDataFromParser, settings); if (filterResult.blocked) { log(formatBlockReasonLog('PRE-FILTER', filterResult)); return true; // Block the message. } return false; // Don't block. } // Override the global WebSocket constructor. window.WebSocket = function(url, protocols) { const socket = new ORIGINAL_WEBSOCKET(url, protocols); // Create a real WebSocket instance. // Check if this WebSocket is the Fishtank chat WebSocket. if (CHAT_WEBSOCKET_URL_PATTERN.test(url)) { log('[PRE-FILTER] Chat WebSocket identified, attaching proxy:', url); const originalAddEventListener = socket.addEventListener; // Intercept 'message' event listeners. socket.addEventListener = function(type, listener, options) { if (type === 'message') { // Wrap the original listener to pre-filter messages. const wrappedListener = event => { const parsedMsg = parseWebSocketMessage(event.data); if (shouldPreFilterMessage(parsedMsg)) return; // If blocked, don't call original listener. if(typeof listener === 'function') listener.call(this, event); // Otherwise, proceed. }; return originalAddEventListener.call(this, type, wrappedListener, options); } return originalAddEventListener.call(this, type, listener, options); // For other event types, pass through. }; // Intercept 'onmessage' property assignments. let onmessageDescriptor = Object.getOwnPropertyDescriptor(ORIGINAL_WEBSOCKET.prototype, 'onmessage'); // Fallback for some environments where prototype descriptor might not be standard for instances. if (!onmessageDescriptor && Object.prototype.hasOwnProperty.call(socket, 'onmessage')) { onmessageDescriptor = Object.getOwnPropertyDescriptor(socket, 'onmessage'); } let actualOnMessageHandler = null; // Store the actual onmessage handler. Object.defineProperty(socket, 'onmessage', { configurable: true, enumerable: true, get() { // Return the original getter's value or our stored handler. return (onmessageDescriptor && onmessageDescriptor.get) ? onmessageDescriptor.get.call(socket) : actualOnMessageHandler; }, set(callback) { actualOnMessageHandler = callback; // Wrap the assigned callback to pre-filter messages. const wrappedCallback = event => { const parsedMsg = parseWebSocketMessage(event.data); if (shouldPreFilterMessage(parsedMsg)) return; // If blocked, don't call. if (actualOnMessageHandler && typeof actualOnMessageHandler === 'function') actualOnMessageHandler.call(socket, event); // Otherwise, proceed. }; // Use original setter if available, otherwise directly assign our wrapped callback. if (onmessageDescriptor && onmessageDescriptor.set) onmessageDescriptor.set.call(socket, wrappedCallback); else actualOnMessageHandler = wrappedCallback; } }); } return socket; // Return the (potentially proxied) socket. }; // Ensure the WebSocket prototype chain is correctly maintained. if (ORIGINAL_WEBSOCKET.prototype) { window.WebSocket.prototype = ORIGINAL_WEBSOCKET.prototype; window.WebSocket.prototype.constructor = window.WebSocket; } else { log('[PRE-FILTER] Warning: ORIGINAL_WEBSOCKET.prototype is undefined. Proxy might be incomplete.'); } log('[PRE-FILTER] WebSocket proxy installed.'); // --- CSS Styles for UI elements --- GM_addStyle(` /* Ensure the chat header establishes a positioning context */ div[class*="chat_header__"] { position: relative !important; } /* Shift the existing channel selection menu to make space for our controls */ div[class*="chat-room-selector"] { margin-right: 62px !important; /* Adjust this value if needed */ } /* Container for script's control buttons in the chat header. */ .fte-controls { display: flex; align-items: center; /* This will vertically center buttons if they have different heights */ position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 10; flex-shrink: 0; } /* General styling for script's buttons. */ .fte-btn { box-sizing: border-box; padding: 2px; cursor: pointer; border: 1px solid #404040; background-color: #282930; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; transition: background-color 0.2s, border-color 0.2s, transform 0.1s; line-height: 0; /* ADDED: Prevent line-height from interfering with vertical alignment */ } /* Styling for the settings panel button (gear icon). */ .fte-settings-btn-app { box-sizing: border-box; background: transparent; border: none; /* MODIFIED: Increased vertical padding */ padding: 4px 3px; /* Was: padding: 3px; (Now 4px top/bottom, 3px left/right) */ margin-left: 5px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #b0b3b8; transition: color 0.2s, transform 0.1s; line-height: 0; /* ADDED: Prevent line-height from interfering with vertical alignment */ } .fte-btn:hover { background-color: #36393f; border-color: #505050; } .fte-btn.active { background-color: #701f1f; border-color: #501010; } .fte-btn.active:hover { background-color: #802f2f; border-color: #601818; } .fte-btn:active { transform: translateY(1px); } .fte-settings-btn-app:hover { color: #e1e3e6; } .fte-settings-btn-app:active { transform: translateY(1px); } .fte-btn svg, .fte-settings-btn-app svg { width: 15px; height: 15px; display: block; } /* Class to hide filtered messages. */ .fte-hidden { display: none !important; } /* Main settings panel styling. */ .fte-settings-panel { display: none; position: absolute; background-color: #1a1b1e; border: 1px solid #2d2f33; border-radius: 7px; padding: 9px; z-index: 2147483647 !important; min-width: 300px; box-shadow: 0 3px 7px rgba(0,0,0,0.15), 0 10px 20px rgba(0,0,0,0.15); color: #dcddde; font-family: 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif; font-size: 13px; pointer-events: auto !important; overflow: visible !important; } .fte-settings-panel * { pointer-events: auto !important; } /* Styling for sections within the settings panel. */ .fte-settings-section { background-color: #232529; padding: 14px; border-radius: 5px; margin-bottom: 10px; box-shadow: 0 1px 2px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.08); } .fte-settings-section:last-child { margin-bottom: 0; } /* Header for each settings section. */ .fte-settings-section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 5px; border-bottom: 1px solid #303236; } .fte-settings-section-header h4 { font-size: 1.0em; color: #e0e2e5; font-weight: 600; margin: 0; text-transform: uppercase; letter-spacing: 0.4px; display: inline; } /* Toggle switch styling. */ .fte-toggle-switch-label { display: flex; align-items: center; cursor: pointer; font-size: 0.92em; color: #c8cacd; margin-bottom: 7px; } .fte-toggle-switch-label:last-of-type { margin-bottom: 0; } .fte-toggle-switch { position: relative; display: inline-block; width: 33px; height: 18px; margin-right: 10px; } .fte-toggle-switch input { opacity: 0; width: 0; height: 0; } .fte-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #444950; transition: .3s ease; border-radius: 18px; } .fte-toggle-slider:before { position: absolute; content: ""; height: 13px; width: 13px; left: 2.5px; bottom: 2.5px; background-color: white; transition: .3s ease; border-radius: 50%; } .fte-toggle-switch input:checked + .fte-toggle-slider { background-color: #007bff; } .fte-toggle-switch input:focus + .fte-toggle-slider { box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.3); } .fte-toggle-switch input:checked + .fte-toggle-slider:before { transform: translateX(15px); } /* Input group for text input and add button. */ .fte-input-group { display: flex; margin-top: 9px; } .fte-text-input { flex-grow: 1; padding: 8px 10px; background-color: #1c1d20; border: 1px solid #383a3f; color: #e1e3e6; border-radius: 4px; font-size: 0.92em; transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; } .fte-text-input::placeholder { color: #6a6d73; } .fte-text-input:focus { border-color: #007bff; background-color: #24262a; box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); outline: none; } /* Action button style (e.g., "Add" button). */ .fte-action-button { padding: 8px 12px; margin-left: 7px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 0.92em; font-weight: 500; display: inline-flex; align-items: center; transition: background-color 0.2s, transform 0.1s; } .fte-action-button:hover { background-color: #0069d9; } .fte-action-button:active { transform: translateY(1px); } .fte-action-button svg { margin-right: 5px; } /* Container for lists (blocked usernames/keywords). */ .fte-list-container { margin-top: 9px; max-height: 95px; overflow-y: auto; background-color: #1c1d20; border-radius: 4px; padding: 5px; border: 1px solid #383a3f; scrollbar-width: thin; scrollbar-color: #4f545c #222427; } .fte-list-container::-webkit-scrollbar { width: 7px; } .fte-list-container::-webkit-scrollbar-thumb { background-color: #4f545c; border-radius: 3px; border: 1.5px solid #1c1d20; } .fte-list-container::-webkit-scrollbar-track { background-color: #1c1d20; border-radius: 3px; } /* List styling. */ .fte-list { list-style: none; padding: 0; margin: 0; } .fte-list-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 9px; margin-bottom: 3px; background-color: #2b2d31; border-radius: 3px; color: #b0b3b8; font-size: 0.9em; transition: background-color 0.15s; } .fte-list-item:last-child { margin-bottom: 0; } .fte-list-item:hover { background-color: #36393f; color: #dadce0; } .fte-list-item span { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; margin-right: 7px; } /* Remove button for list items (trash icon). */ .fte-list-remove-btn { background: transparent; border: none; color: #828589; cursor: pointer; padding: 2px; line-height: 1; display: inline-flex; align-items: center; transition: color 0.2s, transform 0.1s; border-radius: 3px; } .fte-list-remove-btn:hover { color: #f04747; background-color: rgba(240, 71, 71, 0.1); } .fte-list-remove-btn:active { transform: translateY(1px); } /* Tooltip styling. */ .fte-tooltip-container { position: relative; display: inline-flex; align-items: center; justify-content: center; cursor: help; color: #72767d; transition: color 0.2s; margin-left: 7px; vertical-align: middle; } .fte-tooltip-container svg { width: 13px; height: 13px; display: block; } .fte-tooltip-container:hover { color: #b9bbbe; } .fte-tooltip-text { visibility: hidden; opacity: 0; width: 210px; background-color: #111214; color: #dcdde0; text-align: left; font-size: 0.9em; font-weight: normal; text-transform: none; letter-spacing: normal; line-height: 1.4; border-radius: 4px; border: 1px solid #303236; padding: 9px 10px; position: absolute; z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,0.4); transition: opacity 0.2s ease-in-out, visibility 0.2s; bottom: calc(100% + 9px); left: 50%; transform: translateX(-50%); transform-origin: bottom center; } .fte-tooltip-text::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border-width: 5px; border-style: solid; border-color: #111214 transparent transparent transparent; } .fte-tooltip-container:hover .fte-tooltip-text { visibility: visible; opacity: 1; } `); /** * Escapes HTML special characters in a string to prevent XSS. * @param {string} str - The string to escape. * @returns {string} The escaped string. */ function escapeHTML(str) { if (typeof str !== 'string') return ''; // Handle non-string inputs. const div = document.createElement('div'); div.appendChild(document.createTextNode(str)); // Use text node to handle escaping. return div.innerHTML; } /** * Modifies the display of the online user count in the chat header * to remove the word "Online". This version carefully targets only * the text node containing " Online" to preserve internal HTML structure. * @param {HTMLElement} chatHeader - The chat header element, used as a root for searching and observing. */ function modifyOnlineCountDisplay(chatHeader) { if (!chatHeader) { log('[OnlineCountMod] Chat header not provided.'); return; } const presenceSelector = '.chat_presence__90XuO'; // Specific selector for the online count element const onlineTextSuffixPattern = /\s+Online$/i; // Matches " Online" at the end of a string const updatePresenceText = () => { const presenceEl = chatHeader.querySelector(presenceSelector); if (presenceEl) { let textModifiedInLoop = false; // Iterate over child nodes of the presence element for (const child of presenceEl.childNodes) { // We are looking for a TEXT_NODE whose value ends with " Online" if (child.nodeType === Node.TEXT_NODE && onlineTextSuffixPattern.test(child.nodeValue)) { // Modify only this specific text node's value child.nodeValue = child.nodeValue.replace(onlineTextSuffixPattern, ''); log('[OnlineCountMod] Modified text node. New value:', `"${child.nodeValue.trim()}"`); textModifiedInLoop = true; // Assuming " Online" is unique or we act on the first relevant text node. // This should be the case for "NUMBER Online" or "[NUMBER] Online" formats. break; } } // DEBUG: Log if the presence element was found but no suitable text node was modified. // if (!textModifiedInLoop) { // log('[OnlineCountMod] Presence element found, but no child text node ending with " Online" was modified. Content:', `"${presenceEl.innerHTML}"`); // } } else { // log('[OnlineCountMod] Presence element not found with selector:', presenceSelector); } }; // Initial attempt to update the text log('[OnlineCountMod] Initial attempt to modify "Online" text by targeting specific text node.'); updatePresenceText(); // Set up a MutationObserver to watch the chatHeader for changes. const headerObserver = new MutationObserver((mutationsList) => { // A simple check: if any character data or child list change happened within chatHeader, // it's possible the presence text was updated by the site. const potentiallyRelevantMutation = mutationsList.some(mutation => mutation.type === 'characterData' || mutation.type === 'childList' ); if (potentiallyRelevantMutation) { // log('[OnlineCountMod] Detected potential changes in chatHeader, re-evaluating presence text.'); updatePresenceText(); // Re-apply the modification logic } }); // Start observing the chatHeader. headerObserver.observe(chatHeader, { childList: true, characterData: true, subtree: true }); log('[OnlineCountMod] Observer attached to chatHeader to monitor for online count text changes.'); } // --- DOM Filtering --- /** * Processes a chat message DOM node to determine if it should be hidden. * This is a fallback/secondary filter for messages already in the DOM. * @param {HTMLElement} node - The chat message element. */ function processMessageNode(node) { // Basic validation of the node. if (!node || !(typeof node.matches === 'function' || typeof node.querySelector === 'function')) { return; } let messageDetails = {}; // To store extracted message info. let shouldHide = false; // Flag to determine if the message should be hidden. if (settings.masterFilterActive) { // Only process if master filter is on. // Extract details based on message type (emote/system or standard). if (node.matches(EMOTE_MESSAGE_WRAPPER_SELECTOR)) { const userNameEl = node.querySelector(USERNAME_SELECTOR_EMOTE); const domUsername = userNameEl ? userNameEl.textContent.trim() : ''; // Attempt to get text content if available, even from emote messages const textContentEl = node.querySelector('span[class*="message"]'); // A more generic selector for message content const text = textContentEl ? textContentEl.textContent.trim() : ''; messageDetails = { username: domUsername, text: text, emotes: {}, isSystemEmote: true }; // Mark as system emote. } else if (node.matches(CHAT_MESSAGE_WRAPPER_SELECTOR)) { const userId = node.dataset.userId; // User ID from data attribute. const userNameEl = node.querySelector(USERNAME_SELECTOR_DEFAULT); const domUsername = userNameEl ? userNameEl.textContent.trim() : ''; const textEl = node.querySelector(MESSAGE_TEXT_SELECTOR); const text = textEl ? textEl.textContent : ''; // For DOM filtering, `emotes` object is not easily available; pre-filter handles this better. messageDetails = { userId: userId, username: domUsername, text: text, emotes: {}, isSystemEmote: false }; } else { return; // Not a recognized message type. } // Apply filter logic. const filterResult = getBlockReason(messageDetails, settings); if (filterResult.blocked) { shouldHide = true; log(formatBlockReasonLog('DOM', filterResult)); // Log if blocked by DOM filter. } } // Toggle visibility based on filter result. node.classList.toggle('fte-hidden', shouldHide); } /** * Applies filters to all existing chat messages in the DOM. * Useful on script load or when filter settings change. */ function applyAllFiltersToExistingDOM() { log('[DOM-FILTER] Applying filters to all existing messages in DOM.'); // Select all message nodes and process each. document.querySelectorAll(`#${CHAT_MESSAGES_CONTAINER_ID} ${ALL_MESSAGE_SELECTORS_QUERY}`).forEach(processMessageNode); } // --- UI Management --- /** * Updates the appearance and title of the master filter toggle button. */ function updateMasterFilterButton() { if (!masterFilterBtn) return; // Ensure button exists. masterFilterBtn.innerHTML = SVG_ICON_USER_FILTER_CONTROL; // Set icon. masterFilterBtn.classList.toggle('active', settings.masterFilterActive); // Style based on active state. masterFilterBtn.title = `Chat filters ${settings.masterFilterActive ? 'ON' : 'OFF'}. Click to toggle.`; // Tooltip. } /** * Creates and populates the HTML content of the settings panel. */ function createSettingsPanelContent() { // Helper to render a toggle switch. const renderToggle = (key, lbl) => `<label class="fte-toggle-switch-label"><div class="fte-toggle-switch"><input type="checkbox" data-setting="${key}" ${settings[key] ? 'checked' : ''}><span class="fte-toggle-slider"></span></div>${escapeHTML(lbl)}</label>`; // Helper to render list items (e.g., blocked usernames). const renderListItems = (items, type) => items.map((item, i) => `<li class="fte-list-item"><span>${escapeHTML(item)}</span><button class="fte-list-remove-btn" data-${type}-index="${i}" title="Remove">${SVG_ICON_TRASH}</button></li>`).join(''); // Helper to render section headers with optional tooltips. const renderHeader = (title, tooltip) => ` <div class="fte-settings-section-header"> <h4> ${escapeHTML(title)} ${tooltip ? `<span class="fte-tooltip-container">${SVG_ICON_HELP}<span class="fte-tooltip-text">${escapeHTML(tooltip)}</span></span>` : ''} </h4> </div>`; // Tooltip texts for different settings sections. const TOOLTIPS = { GENERAL: "Turns the plugin on or off.", BOT_MUTE: "Options to mute bots and emote messages (such as /roll, /lol, /shrug, or messages made entirely of custom emotes).", // Updated tooltip COMMAND: "Filters user messages which start with ! or > (! is used to command lexxbot, > is used to command grizzbot).", USERNAME: "Filters user messages based on their username (including staff) or clan tag.", KEYWORD: "Filters messages based on words or phrases. You can also add usernames and it will filter any message where that user is tagged.", }; // Generate HTML for blocked usernames list, if not empty. let usernameListHTML = ''; if (settings.blockedUsernames.length > 0) { usernameListHTML = `<div class="fte-list-container" id="fte-username-list-container"><ul class="fte-list">${renderListItems(settings.blockedUsernames, 'username')}</ul></div>`; } // Generate HTML for blocked keywords list, if not empty. let keywordListHTML = ''; if (settings.blockedKeywords.length > 0) { keywordListHTML = `<div class="fte-list-container" id="fte-keyword-list-container"><ul class="fte-list">${renderListItems(settings.blockedKeywords, 'keyword')}</ul></div>`; } // Construct the full HTML for the settings panel. settingsPanelEl.innerHTML = ` <div class="fte-settings-section"> ${renderHeader('General', TOOLTIPS.GENERAL)} ${renderToggle('masterFilterActive', "Plugin Enabled")} </div> <div class="fte-settings-section"> ${renderHeader('Bot & Message Type Muting', TOOLTIPS.BOT_MUTE)} ${renderToggle('muteGrizzbot', `Mute ${GRIZZBOT_USERNAME}`)} ${renderToggle('muteLexxbot', `Mute ${LEXXBOT_USERNAME}`)} ${renderToggle('muteEmotes', "Mute emotes & slash commands (e.g. /roll, /lol, /shrug)")} </div> <div class="fte-settings-section"> ${renderHeader('Command Filter', TOOLTIPS.COMMAND)} ${renderToggle('commandFilterEnabled', "Filter '!' or '>' commands")} </div> <div class="fte-settings-section"> ${renderHeader('Username & Clan Filter', TOOLTIPS.USERNAME)} ${renderToggle('usernameFilterEnabled', "Enabled")} <div class="fte-input-group"><input type="text" id="fte-username-input" class="fte-text-input" placeholder="Add name or clan..."><button id="fte-add-username-btn" class="fte-action-button" title="Add">${SVG_ICON_ADD_PLUS}Add</button></div> ${usernameListHTML} </div> <div class="fte-settings-section"> ${renderHeader('Keyword Filter', TOOLTIPS.KEYWORD)} ${renderToggle('keywordFilterEnabled', "Enabled")} <div class="fte-input-group"><input type="text" id="fte-keyword-input" class="fte-text-input" placeholder="Add keyword/phrase..."><button id="fte-add-keyword-btn" class="fte-action-button" title="Add">${SVG_ICON_ADD_PLUS}Add</button></div> ${keywordListHTML} </div> `; } /** * Handles 'change' events within the settings panel (e.g., toggling a switch). * @param {Event} event - The change event. */ function handleSettingsPanelChange(event) { log('handleSettingsPanelChange fired. Target:', event.target); if (event.target.closest('.fte-tooltip-container')) return; // Ignore changes if it's part of a tooltip interaction. event.stopImmediatePropagation(); // Prevent event from bubbling further if handled here. const target = event.target; // Check if a setting toggle switch was changed. if (target.matches('input[type="checkbox"][data-setting]')) { const settingKey = target.dataset.setting; // Get setting key from data attribute. settings[settingKey] = target.checked; // Update setting value. log(`Setting '${settingKey}' toggled to ${target.checked}`); saveSettings(); // Save updated settings. if (settingKey === "masterFilterActive") updateMasterFilterButton(); // Update main button if master filter changed. applyAllFiltersToExistingDOM(); // Re-apply filters to chat. } } /** * Handles 'click' events within the settings panel (e.g., add/remove buttons). * @param {Event} event - The click event. */ function handleSettingsPanelClick(event) { log('handleSettingsPanelClick fired. Target:', event.target); // Ignore clicks if it's part of a tooltip interaction. if (event.target.closest('.fte-tooltip-container')) { event.stopImmediatePropagation(); return; } event.stopImmediatePropagation(); // Prevent event from bubbling further. const target = event.target.closest('button'); // Find the closest button ancestor. if (!target) { // If click wasn't on a button we care about. log('Clicked inside panel, but not on a button we handle.'); return; } log('Button clicked inside panel:', target.id || `data-${Object.keys(target.dataset)[0]}`); // Helper function to process add/remove actions for lists (usernames, keywords). const processList = (listKey, inputId, type, action) => { if (action === 'add') { const inputEl = settingsPanelEl.querySelector(`#${inputId}`); const newItem = inputEl.value.trim(); // Add item if it's not empty and not already in the list (case-insensitive). if (newItem && !settings[listKey].some(item => item.toLowerCase() === newItem.toLowerCase())) { settings[listKey].push(newItem); settings[listKey].sort((a,b) => a.toLowerCase().localeCompare(b.toLowerCase())); // Keep sorted. log(`${type} added: "${newItem}"`); inputEl.value = ''; inputEl.focus(); // Clear input and refocus. } else if (newItem) { // Item already exists or is invalid. log(`${type} "${newItem}" already exists or is invalid.`); inputEl.value = ''; inputEl.focus(); } else { // Input was empty. inputEl.focus(); } } else if (action === 'remove') { // Remove item by index. const itemIndex = parseInt(target.dataset[`${type.toLowerCase()}Index`], 10); if (!isNaN(itemIndex) && itemIndex >= 0 && itemIndex < settings[listKey].length) { const removed = settings[listKey].splice(itemIndex, 1)[0]; log(`${type} removed: "${removed}"`); } } saveSettings(); // Save changes. createSettingsPanelContent(); // Re-render panel to reflect list changes. applyAllFiltersToExistingDOM(); // Re-apply filters. }; // Determine which button was clicked and call processList accordingly. if (target.id === 'fte-add-username-btn') processList('blockedUsernames', 'fte-username-input', 'Username', 'add'); else if (target.id === 'fte-add-keyword-btn') processList('blockedKeywords', 'fte-keyword-input', 'Keyword', 'add'); else if (target.dataset.usernameIndex !== undefined) processList('blockedUsernames', '', 'Username', 'remove'); else if (target.dataset.keywordIndex !== undefined) processList('blockedKeywords', '', 'Keyword', 'remove'); } /** * Creates and adds the script's UI controls (filter button, settings button, panel) to the chat header. * @param {HTMLElement} chatHeader - The chat header element. * @returns {boolean} True if controls were added, false otherwise. */ function addControls(chatHeader) { if (chatHeader.querySelector('.fte-controls')) return true; // Controls already added. controlsContainer = document.createElement('div'); controlsContainer.className = 'fte-controls'; // Create master filter toggle button. masterFilterBtn = document.createElement('button'); masterFilterBtn.className = 'fte-btn'; masterFilterBtn.addEventListener('click', () => { settings.masterFilterActive = !settings.masterFilterActive; // Toggle setting. saveSettings(); updateMasterFilterButton(); // Update button appearance. applyAllFiltersToExistingDOM(); // Re-apply filters. }); controlsContainer.appendChild(masterFilterBtn); updateMasterFilterButton(); // Initial button state. // Create settings panel button. settingsBtn = document.createElement('button'); settingsBtn.className = 'fte-settings-btn-app'; settingsBtn.innerHTML = SVG_ICON_SETTINGS_GEAR; settingsBtn.title = 'Chat Enhancer Settings'; settingsBtn.addEventListener('click', (e) => { // Use capturing phase for reliable panel opening/closing. log('Settings button clicked.'); e.stopImmediatePropagation(); // Prevent other listeners from interfering. const isVisible = settingsPanelEl.style.display === 'block'; if (!isVisible) { createSettingsPanelContent(); // Build panel content. // Temporarily show panel off-screen to calculate dimensions for positioning. settingsPanelEl.style.visibility = 'hidden'; settingsPanelEl.style.display = 'block'; setTimeout(() => { // Defer positioning to allow DOM to update. const panelWidth = settingsPanelEl.offsetWidth; const panelHeight = settingsPanelEl.offsetHeight; settingsPanelEl.style.display = 'none'; // Hide again before final positioning. settingsPanelEl.style.visibility = 'visible'; // Calculate optimal panel position relative to controls, within viewport. const controlsRect = controlsContainer.getBoundingClientRect(); let panelTop = controlsRect.bottom + window.scrollY + 6; let panelLeft = controlsRect.right + window.scrollX - panelWidth; // Align right edge of panel with right edge of controls. const viewportWidth = document.documentElement.clientWidth; const viewportHeight = document.documentElement.clientHeight; const margin = 12; // Margin from viewport edges. // Adjust if panel overflows viewport. if (panelLeft + panelWidth > viewportWidth + window.scrollX - margin) panelLeft = viewportWidth + window.scrollX - panelWidth - margin; if (panelLeft < window.scrollX + margin) panelLeft = window.scrollX + margin; if (panelTop + panelHeight > viewportHeight + window.scrollY - margin) { // If overflows bottom // Try to position above controls if space allows. if (controlsRect.top + window.scrollY - panelHeight - 6 > window.scrollY + margin) panelTop = controlsRect.top + window.scrollY - panelHeight - 6; else panelTop = Math.max(margin, viewportHeight + window.scrollY - panelHeight - margin); // Else, fit at bottom with margin. } if (panelTop < window.scrollY + margin) panelTop = window.scrollY + margin; // Ensure not off-screen top. settingsPanelEl.style.top = panelTop + 'px'; settingsPanelEl.style.left = panelLeft + 'px'; settingsPanelEl.style.display = 'block'; // Show panel. // Focus first input field in the panel for better UX. const firstInput = settingsPanelEl.querySelector('#fte-username-input') || settingsPanelEl.querySelector('#fte-keyword-input'); if(firstInput) firstInput.focus(); }, 0); } else { settingsPanelEl.style.display = 'none'; // Hide panel if already visible. } log(`Settings panel display: ${settingsPanelEl.style.display}`); }, true); // Use capture phase for this listener. controlsContainer.appendChild(settingsBtn); // Create the settings panel element itself (initially hidden). settingsPanelEl = document.createElement('div'); settingsPanelEl.className = 'fte-settings-panel'; // Add event listeners for changes and clicks within the panel (using capture phase for reliability). settingsPanelEl.addEventListener('change', handleSettingsPanelChange, true); settingsPanelEl.addEventListener('click', handleSettingsPanelClick, true); document.body.appendChild(settingsPanelEl); // Append panel to body for global positioning. // Insert controls into the chat header. const roomSelector = chatHeader.querySelector('div[class*="chat-room-selector"]'); if (roomSelector) roomSelector.insertAdjacentElement('beforebegin', controlsContainer); // Insert before room selector if present. else chatHeader.appendChild(controlsContainer); // Otherwise, append to header. log('Controls added.'); // Add global click listener to close settings panel when clicking outside of it. document.addEventListener('click', (e) => { if (settingsPanelEl && settingsPanelEl.style.display === 'block' && // If panel is visible e.target !== settingsBtn && !settingsBtn.contains(e.target) && // And click is not on settings button !settingsPanelEl.contains(e.target)) { // And click is not inside the panel if (e.target.closest('.fte-tooltip-container')) return; // Don't close if clicking a tooltip. log('Clicked outside settings panel and button, closing.'); settingsPanelEl.style.display = 'none'; } }, true); // Use capture phase. return true; } /** * Sets up a MutationObserver to watch for new chat messages added to the DOM * and apply filters to them. * @returns {boolean} True if observer was set up, false otherwise. */ function observeChatForDOMChanges() { const chatContainer = document.getElementById(CHAT_MESSAGES_CONTAINER_ID); if (!chatContainer) { log('[DOM-FILTER] Chat container not found.'); return false; } applyAllFiltersToExistingDOM(); // Filter any messages already present. if (chatObserver) chatObserver.disconnect(); // Disconnect previous observer if any. chatObserver = new MutationObserver(mutations => { if (!settings.masterFilterActive) return; // Only observe if master filter is on. for (const mutation of mutations) { if (mutation.type === 'childList') { // If new child nodes were added. for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { // Ensure it's an element. // Check if the added node itself is a message, or contains messages. if (node.matches(ALL_MESSAGE_SELECTORS_QUERY)) processMessageNode(node); else node.querySelectorAll(ALL_MESSAGE_SELECTORS_QUERY).forEach(processMessageNode); } } } } }); // Start observing the chat container for child additions and subtree changes. chatObserver.observe(chatContainer, { childList: true, subtree: true }); log('[DOM-FILTER] MutationObserver attached.'); return true; } /** * Initializes UI-dependent parts of the script, such as adding controls and setting up the DOM observer. * Retries if necessary DOM elements are not yet available. */ function initUIDependantParts() { log('Initializing UI and DOM observer...'); const chatHeader = document.querySelector(CHAT_HEADER_SELECTOR); const chatMessages = document.getElementById(CHAT_MESSAGES_CONTAINER_ID); // Check if essential Fishtank elements are present. if (chatHeader && chatMessages) { // Call the new function to modify the "ONLINE" text display modifyOnlineCountDisplay(chatHeader); // Try to add controls and set up observer. if (addControls(chatHeader) && observeChatForDOMChanges()) { log('UI & DOM observer init successful.'); } else { // If failed, retry after a delay. log('Failed to fully init UI/DOM observer parts, retrying.'); setTimeout(initUIDependantParts, 2000); } } else { // If core elements not found, retry after a delay. log('Core Fishtank elements not found. Retrying...'); setTimeout(initUIDependantParts, 1500); } } // Start initialization based on document ready state. // `@run-at document-start` means this script runs very early. if (document.readyState === 'loading') { // If DOM is still loading, wait for DOMContentLoaded. document.addEventListener('DOMContentLoaded', initUIDependantParts); } else { // If DOM is already interactive or complete, initialize immediately. initUIDependantParts(); } })();