// ==UserScript==
// @name Twitch RaidHammer - Easily ban multiple accounts during hate raids
// @description A tool for moderating Twitch easier during hate raids
// @namespace https://github.com/victornpb/twitch-mass-ban
// @version 1.1.4
// @match *://*.twitch.tv/*
// @run-at document-idle
// @author victornpb
// @homepageURL https://github.com/victornpb/twitch-mass-ban
// @supportURL https://github.com/victornpb/twitch-mass-ban/discussions
// @contributionURL https://www.buymeacoffee.com/vitim
// @grant none
// @license MIT
// ==/UserScript==
/* jshint esversion: 8 */
(function () {
var html = /*html*/`
<div class="raidhammer">
<style>
.raidhammer {
position: fixed;
bottom: 10px;
right: 350px;
z-index: 99999999;
background-color: var(--color-background-base);
color: var(--color-text-base);
border: var(--border-width-default) solid var(--color-border-base);
box-shadow: var(--shadow-elevation-2);
padding: 5px;
min-width: 300px;
}
.raidhammer .header {
display: flex;
}
.raidhammer .logo {
font-weight: var(--font-weight-semibold);
min-height: 30px;
line-height: 30px;
--color: var(--color-text-link);
}
.raidhammer h6 {
color: var(--color-hinted-grey-7);
}
.raidhammer h6 button {
height: auto;
background: none;
}
.raidhammer .list {
padding: 8px;
min-height: 8em;
max-height: 500px;
overflow-y: auto;
background: var(--color-background-body);
}
.raidhammer .list span {
font-weight: var(--font-weight-semibold);
}
.raidhammer .empty {
padding: 2em;
text-align: center;
opacity: 0.85;
}
.raidhammer button {
padding: 0 .5em;
margin: 1px;
font-weight: var(--font-weight-semibold);
border-radius: var(--border-radius-medium);
font-size: var(--button-text-default);
height: var(--button-size-default);
background-color: var(--color-background-button-secondary-default);
color: var(--color-text-button-secondary);
min-width: 30px;
text-align: center;
}
.raidhammer button.ban {
var(--color-text-button-primary);
background: #f44336;
min-width: 60px;
}
.raidhammer button.banAll {
var(--color-text-button-primary);
background: #f44336;
min-width: 60px;
}
.raidhammer .import {
background: var(--color-background-body);
border: var(--border-width-default) solid var(--color-border-base);
padding: 3px;
}
.raidhammer textarea {
background: var(--color-background-base);
color: var(--color-text-base);
padding: .5em;
font-size: 10pt;
width: 100%;
min-height: 8em;
}
.raidhammer .footer {
font-size: 7pt;
text-align: center;
}
</style>
<div class="header">
<span style="flex-grow: 1;"></span>
<h5 class="logo">
<a href="https://github.com/victornpb/twitch-mass-ban" target="_blank">RaidHammer</a>
<samp>1.1.3</samp>
</h5>
<span style="flex-grow: 1;"></span>
<button class="closeBtn">X</button>
</div>
<div class="import" style="display:none;">
<div>Mass BAN</div>
<textarea placeholder="Type one username per line"></textarea>
<div style="text-align:right;">
<button class="cancelBtn">Cancel</button>
<button class="importBtn">Add to list</button>
</div>
</div>
<div class="body">
<h6>
Usernames
</h6>
<div class="list"></div>
<div style="display: flex; margin: 5px;">
<span style="flex-grow: 1;"></span>
<button class="ignoreAll">Ignore All</button>
<button class="banAll">Ban All</button>
</div>
</div>
<div class="footer"><a href="https://github.com/victornpb/twitch-mass-ban/issues" target="_blank">Issues or help</a>
</div>
</div>
`;
const LOGPREFIX = '[RAIDHAMMER]';
// modal
const d = document.createElement("div");
d.style.display = 'none';
d.innerHTML = html;
const textarea = d.querySelector("textarea");
// activation button
const activateBtn = document.createElement('button');
activateBtn.innerHTML = `
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 1280 1280" style="fill: currentcolor;">
<path d="M517 1c-16 3-28 10-41 22l-10 10 161 160 161 161 2-2c6-4 17-19 21-25 10-19 12-44 4-64-6-14-5-13-120-129L576 17c-8-7-18-12-27-15-8-1-25-2-32-1zM249 250 77 422l161 161 161 161 74-74 74-75 18 19 18 18-2 4c-4 6-4 14-1 20a28808 28808 0 0 0 589 621c4 2 6 3 13 3 6 0 8-1 13-3 6-4 79-77 82-83 4-9 4-21-2-29l-97-93-235-223-211-200c-51-47-73-68-76-69-6-3-13-3-19 0l-5 3-18-18-18-18 74-74 74-74-161-161L422 77 249 250zM23 476a75 75 0 0 0-10 95c4 6 219 222 231 232 8 7 16 11 26 14 6 2 10 2 22 2s14 0 22-2l14-6c5-4 20-16 24-21l2-2-161-161L32 466l-9 10z"/>
</svg>
`;
activateBtn.style.cssText = `
display: inline-flex;
-webkit-box-align: center;
align-items: center;
-webkit-box-pack: center;
justify-content: center;
user-select: none;
height: var(--button-size-default);
width: var(--button-size-default);
border-radius: var(--border-radius-medium);
background-color: var(--color-background-button-text-default);
color: var(--color-fill-button-icon);
`;
activateBtn.setAttribute('title', 'RaidHammer');
activateBtn.onclick = toggle;
let enabled;
let watchdogTimer;
function appendActivatorBtn() {
const modBtn = document.querySelector('[data-test-selector="mod-view-link"]');
if (modBtn) {
const twitchBar = modBtn.parentElement.parentElement.parentElement;
if (twitchBar && !twitchBar.contains(activateBtn)) {
console.log(LOGPREFIX, 'Mod tools available. Adding button...');
twitchBar.insertBefore(activateBtn, twitchBar.firstChild);
document.body.appendChild(d);
if (!enabled) {
console.log(LOGPREFIX, 'Started chatWatchdog...');
watchdogTimer = setInterval(chatWatchdog, 500);
enabled = true;
}
}
} else if (document.location.toString().includes('/moderator/')){
const chatBtn = document.querySelector('[data-a-target="chat-send-button"]');
const twitchBar = chatBtn.parentElement.parentElement.parentElement;
if (twitchBar && !twitchBar.contains(activateBtn)) {
console.log(LOGPREFIX, 'Mod tools available. Adding button...');
twitchBar.insertBefore(activateBtn, twitchBar.firstChild);
document.body.appendChild(d);
if (!enabled) {
console.log(LOGPREFIX, 'Started chatWatchdog...');
watchdogTimer = setInterval(chatWatchdog, 500);
enabled = true;
}
}
}
else {
if (enabled) {
console.log(LOGPREFIX, 'Mod tools not found. Stopped chatWatchdog!');
clearInterval(watchdogTimer);
watchdogTimer = enabled = false;
hide();
}
}
}
setInterval(appendActivatorBtn, 5000);
//events
d.querySelector(".ignoreAll").onclick = ignoreAll;
d.querySelector(".banAll").onclick = banAll;
d.querySelector(".closeBtn").onclick = hide;
d.querySelector(".import button.importBtn").onclick = importList;
d.querySelector(".import button.cancelBtn").onclick = toggleImport;
// delegated events
d.addEventListener('click', e => {
const target = e.target;
if (target.matches('.ignore')) ignoreItem(target.dataset.user);
if (target.matches('.ban')) banItem(target.dataset.user);
if (target.matches('.accountage')) accountage(target.dataset.user);
if (target.matches('.toggleImport')) toggleImport();
});
const delay = t => new Promise(r => setTimeout(r, t));
function show() {
console.log(LOGPREFIX, 'Show');
d.style.display = '';
renderList();
}
function hide() {
console.log(LOGPREFIX, 'Hide');
d.style.display = 'none';
}
function toggle() {
if (d.style.display !== 'none') hide();
else show();
}
function toggleImport() {
const importDiv = d.querySelector(".import");
const body = d.querySelector(".body");
if (importDiv.style.display !== 'none') {
importDiv.style.display = 'none';
body.style.display = '';
}
else {
importDiv.style.display = '';
body.style.display = 'none';
d.querySelector(".import textarea").focus();
}
}
function importList() {
const textarea = d.querySelector(".import textarea");
const lines = textarea.value.split(/\n/).map(line => line.trim()).filter(Boolean);
for (const line of lines) {
if (/^[\w_]+$/.test(line)) queueList.add(line);
}
textarea.value = '';
toggleImport();
renderList();
}
let queueList = new Set();
let ignoredList = new Set();
let bannedList = new Set();
function chatWatchdog() {
const recentNames = extractRecent();
if (recentNames.length) {
const newNames = recentNames
.filter(name => !queueList.has(name))
.filter(name => !ignoredList.has(name))
.filter(name => !bannedList.has(name));
if (newNames.length) {
newNames.forEach(name => queueList.add(name));
onFollower();
}
}
}
function parseChat() {
return Array.from(document.querySelectorAll('[data-test-selector="chat-line-message"]')).map(chat => {
return {
username: chat.querySelector('[data-test-selector="message-username"]').innerText,
message: chat.querySelector('[data-test-selector="chat-line-message-body"]').innerText,
// timestamp: chat.querySelector('[data-test-selector="chat-timestamp"]').innerText,
};
});
}
function extractRecent() {
let newFollowers = new Set();
const messages = parseChat().filter(m => m.username === 'StreamElements' || m.username === 'Streamlabs');
for (const { message } of messages) {
const match = (
message.match(/Thank you for following ([\w_]+)/) ||
message.match(/Welcome! ([\w_]+) Thank you for following!/)
);
if (match) newFollowers.add(match[1]);
}
return [...newFollowers];
}
function onFollower() {
console.log(LOGPREFIX, 'onFollower', queueList);
renderList();
show();
}
function ignoreAll() {
console.log(LOGPREFIX, 'Ignoring all...', queueList);
for (const user of queueList) {
ignoreItem(user);
}
}
async function banAll() {
console.log(LOGPREFIX, 'Banning all...', queueList);
for (const user of queueList) {
banItem(user);
await delay(250);
}
}
function accountage(user) {
console.log(LOGPREFIX, 'Accountage', user);
sendMessage('!accountage ' + user);
}
function ignoreItem(user) {
console.log(LOGPREFIX, 'Ignored user', user);
queueList.delete(user);
ignoredList.add(user);
renderList();
if (queueList.size === 0) hide(); // auto hide on the last
}
function banItem(user) {
console.log(LOGPREFIX, 'Ban user', user);
queueList.delete(user);
bannedList.add(user);
sendMessage('/ban ' + user);
renderList();
}
function sendMessage(msg) {
try{
sendMessageOld(msg);
}
catch(_){
sendMessageSlate(msg);
}
}
function sendMessageOld(msg) {
const textarea = document.querySelector("[data-a-target='chat-input']");
const nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
nativeTextAreaValueSetter.call(textarea, msg);
const event = new Event('input', { bubbles: true });
textarea.dispatchEvent(event);
document.querySelector("[data-a-target='chat-send-button']").click();
}
function sendMessageSlate(msg) {
function _injectInput(el, data) {
[
'keydown',
'beforeinput',
//'input',
].forEach((event, i) => {
const eventObj = {
altKey: false,
charCode: 0,
ctrlKey: false,
metaKey: false,
shiftKey: false,
which: '',
keyCode: '',
data: data,
inputType: 'insertText',
key: data,
};
el.dispatchEvent(new InputEvent(event, eventObj));
});
}
function _triggerKeyboardEvent(el, keyCode) {
const eventObj = document.createEventObject ? document.createEventObject() : document.createEvent("Events");
if (eventObj.initEvent) {
eventObj.initEvent("keydown", true, true);
}
eventObj.keyCode = keyCode;
eventObj.which = keyCode;
el.dispatchEvent ? el.dispatchEvent(eventObj) : el.fireEvent("onkeydown", eventObj);
}
const editor = document.querySelector('[data-slate-editor="true"]');
editor.focus();
_injectInput(editor, msg);
_triggerKeyboardEvent(editor, 13);
}
function renderList() {
d.querySelector(".ignoreAll").style.display = queueList.size ? '' : 'none';
d.querySelector(".banAll").style.display = queueList.size ? '' : 'none';
const renderItem = item => `
<li>
<button class="accountage" data-user="${item}" title="Check account age">?</button>
<button class="ignore" data-user="${item}">Ignore</button>
<button class="ban" data-user="${item}">Ban</button>
<span>${item}</span>
</li>
`;
let inner = queueList.size ? [...queueList].map(user => renderItem(user)).join('') : `
<div class="empty">
<h4>Recent followers is empty :)</h4>
<p>Automatically listening for new followers...</p>
<br><br>
<button class="toggleImport" title="Add a list of usernames">Import list</button>
</div>`;
d.querySelector('.list').innerHTML = `
<ul>
${inner}
</ul>
`;
}
})();