// ==UserScript== // @name Twitch Plays Pokemon Chat Filter // @namespace https://github.com/jpgohlke/twitch-chat-filter // @description Hide input commands from the chat. // @include /^https?://(www|beta)\.twitch\.tv\/(twitchplayspokemon(/(chat.*)?)?|chat\/.*channel=twitchplayspokemon.*)$/ // @version 2.6 // @updateURL http://jpgohlke.github.io/twitch-chat-filter/chat_filter.user.js // @grant unsafeWindow // ==/UserScript== /* * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is furnished * to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ /* * chat_filter.user.js * * Feel free to review/compress it yourself; good internet security is important! * Passes http://www.jshint.com on default settings * Contributors: * /u/RenaKunisaki * /u/smog_alado * /u/SRS-SRSLY * /u/schrobby * /u/red_agent * /u/DeathlyDeep * /u/jeff_gohlke * /u/yankjenets * /u/MKody * /u/feha * /u/jakery2 * /u/redopium * /u/codefusion * /u/Zephymastyx * /u/anonveggy */ // ****************** // CODING GUIDELINES // ****************** // - Make sure that the code passes JSHint (http://www.jshint.com) // - Write all code inside the wrapper IIFE to avoid creating global variables. // - Constants and global variables are UPPER_CASE. /* jshint lastsemic:true, eqeqeq:true, sub:true */ /* global unsafeWindow:false */ (function(){ "use strict"; var TCF_VERSION = "2.6" ; var TCF_INFO = "TPP Chat Filter version " + TCF_VERSION + " loaded. Please report bugs and suggestions to https://github.com/jpgohlke/twitch-chat-filter"; // ---------------------------- // Greasemonkey support // ---------------------------- // Greasemonkey userscripts run in a separate environment and cannot use global // variables from the page directly. They need to be accessed via `unsafeWindow` var myWindow; try{ myWindow = unsafeWindow; }catch(e){ myWindow = window; } var $ = myWindow.jQuery; // ============================ // Array Helpers // ============================ function forEach(xs, f){ for(var i=0; i= 0); } // ============================ // Initialization code // ============================ var tcf_initializers = []; function add_initializer(init){ tcf_initializers.push(init); } function run_initializers(){ forEach(tcf_initializers, function(init){ init(); }); } // ============================ // Configuration Settings // ============================ var REQUIRED_SETTING_PARAMS = [ 'name', // Unique identifier for the setting, // used to store it persistently or to generate CSS classes 'comment', // Short description of the setting 'category', // What menu to put this setting under 'defaultValue' // Can be either boolean or list of strings. ]; var OPTIONAL_SETTING_PARAMS = [ 'longComment', // Longer description that shows when you hover over. 'message_filter', // When active, filter new chat messages using this predicate 'message_css', // When active, modify the existing chat lines with these CSS rules. 'message_rewriter' // When active, replace the text of the message with the result of this function ]; function Setting(kv){ // Check for required parameters and typos: forEach(REQUIRED_SETTING_PARAMS, function(param){ if(!(param in kv)){ throw new Error("Missing param " + param); } }); forIn(kv, function(param){ if( REQUIRED_SETTING_PARAMS.indexOf(param) < 0 && OPTIONAL_SETTING_PARAMS.indexOf(param) < 0 ){ throw new Error("Unexpected param " + param); } }); // Initialize members var that = this; forIn(kv, function(key, val){ that[key] = val; }); this._value = null; this._observers = []; } Setting.prototype.getValue = function(){ if(this._value !== null){ return this._value; }else{ return this.defaultValue; } }; Setting.prototype.setValue = function(value){ var oldValue = this.getValue(); this._value = value; var newValue = this.getValue(); forEach(this._observers, function(obs){ obs(newValue, oldValue); }); }; Setting.prototype.reset = function(){ this.setValue(null); }; Setting.prototype.observe = function(onChange){ this._observers.push(onChange); }; Setting.prototype.forceObserverUpdate = function(){ var value = this.getValue(); forEach(this._observers, function(obs){ obs(value, value); }); }; var TCF_SETTINGS_LIST = []; var TCF_SETTINGS_MAP = {}; var TCF_FILTERS = []; var TCF_REWRITERS = []; var TCF_STYLERS = []; function add_setting(kv){ var setting = new Setting(kv); TCF_SETTINGS_LIST.push(setting); TCF_SETTINGS_MAP[setting.name] = setting; if(setting.message_filter ){ TCF_FILTERS.push(setting); } if(setting.message_css ){ TCF_STYLERS.push(setting); } if(setting.message_rewriter){ TCF_REWRITERS.push(setting); } } function get_setting_value(name){ return TCF_SETTINGS_MAP[name].getValue(); } // ---------------------------- // Persistence // ---------------------------- var STORAGE_KEY = "tpp-chat-filter-settings"; var LEGACY_FILTERS_KEY = "tpp-custom-filter-active"; var LEGACY_PHRASES_KEY = "tpp-custom-filter-phrases"; function get_local_storage_item(key){ var item = window.localStorage.getItem(key); return (item ? JSON.parse(item) : null); } function set_local_storage_item(key, value){ window.localStorage.setItem(key, JSON.stringify(value)); } function get_old_saved_settings(){ //For compatibility with older versions of the script. var persisted = {}; var old_filters = get_local_storage_item(LEGACY_FILTERS_KEY); if(old_filters){ forIn(TCF_SETTINGS_MAP, function(name){ forEach(["filters", "rewriters", "stylers"], function(category){ if(old_filters[category].indexOf(name) >= 0){ persisted[name] = true; } }); }); } var old_banned_phrases = get_local_storage_item(LEGACY_PHRASES_KEY); if(old_banned_phrases){ persisted['TppBanCustomWords'] = true; persisted['TppBannedWords'] = old_banned_phrases; } return persisted; } function load_settings(){ var persisted; if(window.localStorage){ persisted = get_local_storage_item(STORAGE_KEY) || get_old_saved_settings(); }else{ persisted = {}; } forIn(TCF_SETTINGS_MAP, function(name, setting){ if(name in persisted){ setting.setValue(persisted[name]); }else{ setting.setValue(null); } }); } function save_settings(){ if(!window.localStorage) return; var persisted = {}; forIn(TCF_SETTINGS_MAP, function(name, setting){ if(setting._value !== null){ persisted[name] = setting._value; } }); set_local_storage_item(STORAGE_KEY, persisted); localStorage.removeItem(LEGACY_FILTERS_KEY); localStorage.removeItem(LEGACY_PHRASES_KEY); } add_initializer(function(){ forEach(TCF_SETTINGS_LIST, function(setting){ setting.observe(function(){ save_settings(); }); }); }); // ============================ // UI // ============================ var CHAT_ROOM_SELECTOR = '.chat-room'; var CHAT_MESSAGE_SELECTOR = '.message'; var CHAT_LINE_SELECTOR = '.chat-line'; var CHAT_TEXTAREA_SELECTOR = ".chat-interface textarea"; var CHAT_BUTTON_SELECTOR = ".send-chat-button button"; function add_custom_css(parts){ $('head').append(''); } // ============================ // Features // ============================ // In this part we define all the settings and filters that we support // and all code that needs to run when the script gets initialized. // --------------------------- // Command Filter // --------------------------- var TPP_COMMANDS = [ "left", "right", "up", "down", "start", "select", "a", "b", "l", "r", "democracy", "anarchy", "wait" ]; var EDIT_DISTANCE_TRESHOLD = 2; // Adapted from https://gist.github.com/andrei-m/982927 // Compute the edit distance between the two given strings function min_edit(a, b) { if(a.length === 0) return b.length; if(b.length === 0) return a.length; var matrix = []; var i,j; // increment along the first column of each row for(i = 0; i <= b.length; i++) { matrix[i] = [i]; } // increment each column in the first row for(j = 0; j <= a.length; j++) { matrix[0][j] = j; } // Fill in the rest of the matrix for(i = 1; i <= b.length; i++) { for(j = 1; j <= a.length; j++) { if(b.charAt(i-1) === a.charAt(j-1)){ matrix[i][j] = matrix[i-1][j-1]; } else { matrix[i][j] = 1 + Math.min( matrix[i-1][j-1], // substitution matrix[i][j-1] , // insertion matrix[i-1][j] // deletion ); } } } return matrix[b.length][a.length]; } function word_is_command(word){ return any(TPP_COMMANDS, function(cmd){ return min_edit(cmd.toLowerCase(), word.toLowerCase()) <= EDIT_DISTANCE_TRESHOLD; }); } function message_is_command(message){ var words = message.split(/\s+/); return /^([0-9]+),([0-9]+)$/.test(message) || all(words, function(word){ if(word.length <= 0){ return true } //For compatibility with possible changes the streamer might introduce in the future, //a command is considered to be a sequence of command words separated by some non-word separators var commands = word.match(/(?:([a-z]+)[^a-z]{0,2})+/ig); return commands && all(commands, function(cmd){ var segments = cmd.match(/[a-z]+/ig); return all(segments, word_is_command); }); }); } add_setting({ name: 'TppFilterCommand', comment: "Emulator commands", longComment: TPP_COMMANDS.join(", "), category: 'filters_category', defaultValue: true, message_filter: message_is_command }); // --------------------------- // Misty meme // --------------------------- // Score-based filter for "Guys, we need to beat Misty" spam. var MISTY_SUBSTRINGS = [ "misty", "whitney", "milk", "guys", "we have to", "we need to", "beat", ]; function message_is_misty(message) { var misty_score = 0; forEach(MISTY_SUBSTRINGS, function(s){ if(str_contains(message, s)){ misty_score++; } }); return (misty_score >= 2); } add_setting({ name: 'TppFilterMisty', comment: 'Misty meme', longComment : "Guys we need to milk Witney", category: 'filters_category', defaultValue: true, message_filter: message_is_misty }); // --------------------------- // Hitler drawings // --------------------------- function message_is_drawing(message){ var nonASCII = 0; for(var i = 0; i < message.length; i++) { var c = message.charCodeAt(i); if(9600 <= c && c <= 9632){ nonASCII++; } } return (nonASCII > 3); } add_setting({ name: 'TppFilterAscii', comment: "Blocky Drawings", longComment: "Stuff like this: \u2591\u2591\u2591\u2591\u2592\u2592\u2592\u2592\u258C \u2580\u2592\u2580\u2590\u2584\u2588", category: 'filters_category', defaultValue: true, message_filter: message_is_drawing }); // --------------------------- // Cyrillic // --------------------------- // Some people use cyrillic characters to write spam that gets past the other filters. function message_is_cyrillic(message){ //Some people use cyrillic characters to write spam that gets past the filter. return /[\u0400-\u04FF]/.test(message); } add_setting({ name: 'TppFilterCyrillic', comment: 'Cyrillic', longComment : "Cyrillic characters in copypastas confuse our other filters", category: 'filters_category', defaultValue: true, message_filter: message_is_cyrillic }); // --------------------------- // Dongers // --------------------------- //typical unicodes of dongers (mostly eyes) var DONGER_CODES = [3720, 9685, 664, 8362, 3232, 176, 8248, 8226, 7886, 3237]; function message_is_donger(message){ var donger_count = 0; for(var i = 0; i < message.length; i++) { var c = message.charCodeAt(i); if(DONGER_CODES.indexOf(c) >= 0) { donger_count++; } } return (donger_count > 1); } add_setting({ name: 'TppFilterDonger', comment: "Dongers", longComment: "\u30FD\u0F3C\u0E88\u0644\u035C\u0E88\u0F3D\uFF89", category: 'filters_category', defaultValue: false, message_filter: message_is_donger }); // --------------------------- // One-word messages // --------------------------- function message_is_small(message){ return message.split(/\s/g).length <= 1; } add_setting({ name: 'TppFilterSmall', comment: "One-word messages", category: 'filters_category', defaultValue: false, message_filter: message_is_small }); // --------------------------- // Walls of text // --------------------------- // For messages that fill up more than 4 lines function message_is_too_long(message){ return (message.length >= 200); } add_setting({ name: 'TppFilterLong', comment: 'Overly long messages', longComment: "Hide messages over 200 characters (around 4 lines)", category: 'filters_category', defaultValue: false, message_filter: message_is_too_long }); // --------------------------- // Pokemon Stadium betting // --------------------------- // Filter betting commands for the parallel pokemon stadium betting game function message_is_bet(message){ return /^\s*\!/.test(message); } add_setting({ name: 'TppFilterBets', comment: "Pokemon Stadium Bets", longComment: "Any message starting with a \"!\". ex.: \"!bet 100 blue\"", category: 'filters_category', defaultValue: true, message_filter: message_is_bet }); // --------------------------- // Copy-paste rewriter // --------------------------- // Replace repetitive text with only one instance of it. // Useful for when people do ctrl-c ctrl-v ctrl-v ctrl-v // in order to increase the size of the message. function rewrite_copy_paste(message){ return message.replace(/(.{4}.*?)(\s*?\1)+/g, "$1"); } add_setting({ name: 'TppRewriteDuplicates', comment: "Copy pasted repetitions", category: 'rewriters_category', defaultValue: true, message_rewriter: rewrite_copy_paste }); // --------------------------- // Zalgo text // --------------------------- //removes unicode characters that are used to cover multiple lines (Oops I spilled my drink) function mop_up_drinks(message){ return message.replace(/[\u0300-\u036F]/g, ''); } add_setting({ name: 'TppMopUpDrinks', comment: "Mop up spilled drinks", category: 'rewriters_category', defaultValue: true, message_rewriter: mop_up_drinks }); // --------------------------- // Lowercase converter // --------------------------- add_setting({ name: 'TppConvertAllcaps', comment: "Lowercase everything", longComment: null, category: 'visual_category', defaultValue: true, message_css: CHAT_MESSAGE_SELECTOR + "{text-transform:lowercase !important;}" }); // --------------------------- // Hide emoticons // --------------------------- var emoticon_regexes = []; add_initializer(function(){ if(myWindow.Twitch){ myWindow.Twitch.api.get("chat/emoticons").then(function(data){ forEach(data.emoticons, function(d){ var regex = d.regex; if(regex.match(/^\w+$/)){ regex = '\\b' + regex + '\\b'; } emoticon_regexes.push(new RegExp(regex, 'g')); }); }); } }); function message_is_only_emoticons(message){ //Detect if a message would look empty if we got rid of all emoticons. var withoutEmoticons = message; forEach(emoticon_regexes, function(regexp){ withoutEmoticons = withoutEmoticons.replace(regexp, ""); }); return (/^\s*$/.test(withoutEmoticons)); } add_setting({ name: 'TppHideEmoticons', comment: "Hide emoticons", category: 'visual_category', defaultValue: false, message_css: CHAT_MESSAGE_SELECTOR + " .emoticon{display:none !important;}", message_filter: message_is_only_emoticons }); // --------------------------- // Uncolor messages // --------------------------- add_setting({ name: 'TppNoColor', comment: "Uncolor messages", longComment: 'Remove color from messages created with the /me command', category: 'visual_category', defaultValue: false, message_css: CHAT_MESSAGE_SELECTOR + " {color:inherit !important;}" }); // --------------------------- // Banned Words // --------------------------- function message_contains_banned_word(message){ var shouldBan = get_setting_value('TppBanCustomWords'); var bannedWords = get_setting_value('TppBannedWords'); return shouldBan && any(bannedWords, function(banned){ return str_contains(message, banned); }); } add_setting({ name: 'TppBanCustomWords', comment: "Activate custom banlist", longComment: "", category: 'customs_category', defaultValue: false, message_css: "#menu-TppBannedWords { display:inherit; }" }); add_initializer(function(){ add_custom_css([ "#menu-TppBannedWords { display:none; }" ]); }); add_setting({ name: 'TppBannedWords', comment: "Banned Phrases", longComment: "If the custom banlist is activated, these messages will be hidden", category: 'customs_category', defaultValue: [], message_filter: message_contains_banned_word }); // ============================ // Settings Control Panel // ============================ //var SETTINGS_BUTTON_SELECTOR = "button.settings"; var SETTINGS_MENU_SELECTOR = ".chat-settings"; add_initializer(function(){ add_custom_css([ ".chat-room { z-index: inherit !important; }", ".chat-settings { z-index: 100 !important; }", ".custom_list_menu li {background: #bbb; display: block; list-style: none; margin: 1px 0; padding: 0 2px}", ".custom_list_menu li a {float: right;}" ]); var settingsMenu = $(SETTINGS_MENU_SELECTOR); // Add a scrollbar to the settings menu if its too long // We need to dynamically update the menu height because its a sibling of the // chat-room div, not its immediate child. var chat_room = $(CHAT_ROOM_SELECTOR); settingsMenu.css("overflow-y", "auto"); function updateMenuHeight(){ var h = chat_room.height(); if(h > 0){ //If we call updateMenuHeight too soon, we might get a // height of zero and would end up hiding the menu settingsMenu.css("max-height", 0.9 * h); } } updateMenuHeight(); setInterval(updateMenuHeight, 500); //In case the initial update cant see the real height yet. $(window).resize(updateMenuHeight); function addBooleanSetting(menuSection, option){ menuSection.append( '' ); var checkbox = $('#' + option.name); checkbox.on('change', function(){ option.setValue( $(this).prop("checked") ); }); option.observe(function(newValue){ checkbox.prop('checked', newValue); }); } function addListSetting(menuSection, option){ menuSection.append( '' + '' + '' + '' + '
' ); function add_list_item(item){ var arr = option.getValue().slice(); if(arr.indexOf(item) < 0){ arr.push(item); option.setValue(arr); } } function remove_list_item(i){ var arr = option.getValue().slice(); arr.splice(i, 1); option.setValue(arr); } function hide_inner_list(){ $('#show-'+option.name).show(); $('#hide-'+option.name).hide(); $('#clear-'+option.name).hide(); $('#list-'+option.name).hide(); } function show_inner_list(){ $('#show-'+option.name).hide(); $('#hide-'+option.name).show(); $('#clear-'+option.name).show(); $('#list-'+option.name).show(); } hide_inner_list(); option.observe(function(newValue){ $('#num-banned-'+option.name).text(newValue.length); var innerList = $('#list-' + option.name); innerList.empty(); forEach(newValue, function(word, i){ innerList.append( $("
  • ") .text(word) .append( $('') .text("[X]") .click(function(){ remove_list_item(i) }) ) ); }); }); //Add new banned item when user hits enter $('#' + option.name).keyup(function(e){ var item = $(this).val().trim(); if(e.keyCode === 13 && item !== ""){ add_list_item(item); $(this).val(''); } }); //open the list of banned items $('#show-' + option.name).click(function(e){ e.preventDefault(); show_inner_list(); }); //close the list of banned items $('#hide-' + option.name).click(function(e){ e.preventDefault(); hide_inner_list(); }); //empty the banned list completely $('#clear-' + option.name).click(function(e){ e.preventDefault(); option.setValue([]); }); } function addMenuSection(name){ $('
    ') .text(name) .appendTo(settingsMenu); var section = $('
    ') .appendTo(settingsMenu); return section; } function addCategoryToSection(menuSection, category){ forEach(TCF_SETTINGS_LIST, function(option){ if(option.category !== category) return; var p = $('

    ') .attr('id', 'menu-'+option.name) .addClass('dropmenu_action') .appendTo(menuSection); var typ = typeof(option.defaultValue); if(typ === 'boolean'){ addBooleanSetting(p, option); }else if(typ === 'object'){ addListSetting(p, option); }else{ throw new Error("Unrecognized setting " + typ); } }); } var filter_sec = addMenuSection("Hide"); addCategoryToSection(filter_sec, 'filters_category'); var rewrite_sec = addMenuSection("Automatically rewrite"); addCategoryToSection(rewrite_sec, 'rewriters_category'); var visual_sec = addMenuSection("Visual tweaks"); addCategoryToSection(visual_sec, 'visual_category'); var custom_sec = addMenuSection("Custom Banlist"); addCategoryToSection(custom_sec, 'customs_category'); var misc_sec = addMenuSection("Misc"); misc_sec.append( $('