Greasy Fork is available in English.

Nitro - MOTD Offensive Filter (Updated 2024)

Displays the possibly offensive word on MOTD using a dictionary of banned words.

// ==UserScript==
// @name         Nitro - MOTD Offensive Filter (Updated 2024)
// @version      1.2.0
// @description  Displays the possibly offensive word on MOTD using a dictionary of banned words.
// @author       existence
// @match        *://*.nitrotype.com/team/*
// @match        *://*.nitromath.com/team/*
// @grant        none
// @license      MIT
// @namespace    https://greasyfork.org/users/858426
// @require      https://cdnjs.cloudflare.com/ajax/libs/dexie/3.2.1-beta.2/dexie.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.3.1/papaparse.min.js
// ==/UserScript==
 
/* globals Dexie Papa */
 
let currentUser, authToken
try {
	currentUser = JSON.parse(JSON.parse(localStorage.getItem("persist:nt")).user)
	authToken = localStorage.getItem("player_token")
} catch {
	console.error("Failed to parse NT User data")
	return
}
 
const canMOTD =
	currentUser?.tag &&
	["captain", "officer"].includes(currentUser.teamRole) &&
	window.location.pathname === `/team/${currentUser.tag}`
if (!canMOTD) {
	console.error("MOTD not available on team announcements")
	return
}
 
const DICTIONARY_URL =
	"https://docs.google.com/spreadsheets/d/1AiRty-2WFOPJiN1hp017g64_0u6-YhnPdxa6a3Enbak/export?gid=0&format=csv&id=1AiRty-2WFOPJiN1hp017g64_0u6-YhnPdxa6a3Enbak"
 
// Dictionary storage
const db = new Dexie("NTMOTDFilterTool")
db.version(1).stores({
	badWords: "word, userDefined",
})
db.open().catch(function (e) {
	console.error("Failed to open up the MOTD Filter database")
})
 
/////////////
//  Utils  //
/////////////
 
// https://github.com/dotcypress/runes#readme
 
const HIGH_SURROGATE_START = 0xd800
const HIGH_SURROGATE_END = 0xdbff
 
const LOW_SURROGATE_START = 0xdc00
 
const REGIONAL_INDICATOR_START = 0x1f1e6
const REGIONAL_INDICATOR_END = 0x1f1ff
 
const FITZPATRICK_MODIFIER_START = 0x1f3fb
const FITZPATRICK_MODIFIER_END = 0x1f3ff
 
const VARIATION_MODIFIER_START = 0xfe00
const VARIATION_MODIFIER_END = 0xfe0f
 
const DIACRITICAL_MARKS_START = 0x20d0
const DIACRITICAL_MARKS_END = 0x20ff
 
const ZWJ = 0x200d
 
const GRAPHEMS = [
	0x0308, // ( ◌̈ ) COMBINING DIAERESIS
	0x0937, // ( ष ) DEVANAGARI LETTER SSA
	0x0937, // ( ष ) DEVANAGARI LETTER SSA
	0x093f, // ( ि ) DEVANAGARI VOWEL SIGN I
	0x093f, // ( ि ) DEVANAGARI VOWEL SIGN I
	0x0ba8, // ( ந ) TAMIL LETTER NA
	0x0bbf, // ( ி ) TAMIL VOWEL SIGN I
	0x0bcd, // ( ◌்) TAMIL SIGN VIRAMA
	0x0e31, // ( ◌ั ) THAI CHARACTER MAI HAN-AKAT
	0x0e33, // ( ำ ) THAI CHARACTER SARA AM
	0x0e40, // ( เ ) THAI CHARACTER SARA E
	0x0e49, // ( เ ) THAI CHARACTER MAI THO
	0x1100, // ( ᄀ ) HANGUL CHOSEONG KIYEOK
	0x1161, // ( ᅡ ) HANGUL JUNGSEONG A
	0x11a8, // ( ᆨ ) HANGUL JONGSEONG KIYEOK
]
 
function runes(string) {
	if (typeof string !== "string") {
		throw new Error("string cannot be undefined or null")
	}
	const result = []
	let i = 0
	let increment = 0
	while (i < string.length) {
		increment += nextUnits(i + increment, string)
		if (isGraphem(string[i + increment])) {
			increment++
		}
		if (isVariationSelector(string[i + increment])) {
			increment++
		}
		if (isDiacriticalMark(string[i + increment])) {
			increment++
		}
		if (isZeroWidthJoiner(string[i + increment])) {
			increment++
			continue
		}
		result.push(string.substring(i, i + increment))
		i += increment
		increment = 0
	}
	return result
}
 
// Decide how many code units make up the current character.
// BMP characters: 1 code unit
// Non-BMP characters (represented by surrogate pairs): 2 code units
// Emoji with skin-tone modifiers: 4 code units (2 code points)
// Country flags: 4 code units (2 code points)
// Variations: 2 code units
function nextUnits(i, string) {
	const current = string[i]
	// If we don't have a value that is part of a surrogate pair, or we're at
	// the end, only take the value at i
	if (!isFirstOfSurrogatePair(current) || i === string.length - 1) {
		return 1
	}
 
	const currentPair = current + string[i + 1]
	let nextPair = string.substring(i + 2, i + 5)
 
	// Country flags are comprised of two regional indicator symbols,
	// each represented by a surrogate pair.
	// See http://emojipedia.org/flags/
	// If both pairs are regional indicator symbols, take 4
	if (isRegionalIndicator(currentPair) && isRegionalIndicator(nextPair)) {
		return 4
	}
 
	// If the next pair make a Fitzpatrick skin tone
	// modifier, take 4
	// See http://emojipedia.org/modifiers/
	// Technically, only some code points are meant to be
	// combined with the skin tone modifiers. This function
	// does not check the current pair to see if it is
	// one of them.
	if (isFitzpatrickModifier(nextPair)) {
		return 4
	}
	return 2
}
 
function isFirstOfSurrogatePair(string) {
	return string && betweenInclusive(string[0].charCodeAt(0), HIGH_SURROGATE_START, HIGH_SURROGATE_END)
}
 
function isRegionalIndicator(string) {
	return betweenInclusive(codePointFromSurrogatePair(string), REGIONAL_INDICATOR_START, REGIONAL_INDICATOR_END)
}
 
function isFitzpatrickModifier(string) {
	return betweenInclusive(codePointFromSurrogatePair(string), FITZPATRICK_MODIFIER_START, FITZPATRICK_MODIFIER_END)
}
 
function isVariationSelector(string) {
	return (
		typeof string === "string" &&
		betweenInclusive(string.charCodeAt(0), VARIATION_MODIFIER_START, VARIATION_MODIFIER_END)
	)
}
 
function isDiacriticalMark(string) {
	return (
		typeof string === "string" &&
		betweenInclusive(string.charCodeAt(0), DIACRITICAL_MARKS_START, DIACRITICAL_MARKS_END)
	)
}
 
function isGraphem(string) {
	return typeof string === "string" && GRAPHEMS.indexOf(string.charCodeAt(0)) !== -1
}
 
function isZeroWidthJoiner(string) {
	return typeof string === "string" && string.charCodeAt(0) === ZWJ
}
 
function codePointFromSurrogatePair(pair) {
	const highOffset = pair.charCodeAt(0) - HIGH_SURROGATE_START
	const lowOffset = pair.charCodeAt(1) - LOW_SURROGATE_START
	return (highOffset << 10) + lowOffset + 0x10000
}
 
function betweenInclusive(value, lower, upper) {
	return value >= lower && value <= upper
}
 
const multibyteLength = (text) => {
	return new TextEncoder().encode(text).length
}
 
//////////////////
//  Components  //
//////////////////
 
/** Styles for the following components. */
const style = document.createElement("style")
style.appendChild(
	document.createTextNode(`
.nt-motd-reason {
    margin-top: 20px;
    margin-bottom: 10px;
    border-radius: 8px;
    padding: 10px;
    background-color: #333;
}
.nt-motd-reason.empty {
    display: none;
}
.nt-motd-reason-body {
    display: flex;
    flex-flow: row wrap;
	padding: 1rem;
    background-color: white;
    border-radius: 5px;
	font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
    font-size: 12px;
}
.nt-motd-reason-word {
    display: flex;
    margin-right: 1ch;
}
.nt-motd-reason.disabled .nt-motd-reason-letter {
    cursor: default;
}
.nt-motd-reason .nt-motd-reason-letter {
    color: rgba(0, 0, 0, 0.3);
}
.nt-motd-reason-letter.offensive {
    color: #e00;
    font-weight: 600;
}
.nt-motd-reason-helper {
    color: #acacac;
}
.nt-motd-reason-submit {
    display: flex;
    justify-content: space-between;
    margin: 10px 0;
}
.nt-motd-reason-errors {
    border: 1px solid #D62F3A;
    border-radius: 5px;
    padding: 10px;
    background-color: rgba(214, 47, 58, 0.5);
    color: rgba(255, 255, 255, 0.8);
    font-size: 14px;
    font-style: italic;
}
.nt-motd-reason-error-validation {
    margin-bottom: 0;
}
.nt-motd-reason-error-bad-words {
    background-color: rgba(0, 0, 0, 0.2);
    padding: 10px;
    margin-bottom: 10px;
    border-radius: 5px;
}
.nt-motd-reason-error-bad-words ul {
    margin-bottom: 0;
}
.nt-motd-reason-error-bad-words.has-invalid-form {
    margin-top: 20px;
}`)
)
document.head.appendChild(style)
 
/** Offensive Word Dictionary. */
const OffensiveWordDictionary = ((dictionaryURL) => {
	let loadError = null,
		loading = false,
		badWords = []
 
	/** Grabs list of offensive words from the dictionary. */
	const load = (init) => {
		// Load dictionary
		if (dictionaryURL) {
			loading = true
			return fetch(dictionaryURL)
				.then((resp) => {
					if (!resp.ok) {
						throw new Error("invalid response from dictionary url")
					}
					return resp.text()
				})
				.then((resp) => {
					const parsedCSV = Papa.parse(resp)
					if (parsedCSV.data.length > 1) {
						badWords = parsedCSV.data
							.slice(1)
							.filter((row) => !!row[0])
							.map((row) => row[0])
					}
					if (badWords.length > 0) {
						db.badWords
							.bulkPut(badWords.map((word) => ({ word, userDefined: false })))
							.then(() => console.info("MOTD Offensive Dictionary updated"))
					}
				})
				.catch((err) => {
					console.error("Unable to fetch dictionary (fallback offline)", err)
					loadError = err
					if (init) {
						return db.badWords.each((badWord) => {
							badWords = badWords.concat(badWord.word)
						})
					}
				})
				.finally(() => {
					loading = false
				})
		}
 
		// No dictionary? why?
		loadError = "Offensive Word Dictionary is required, please provide URL to listed words in CSV format."
		console.warn(loadError)
		if (init) {
			return db.badWords.each((badWord) => {
				badWords = badWords.concat(badWord.word)
			})
		}
	}
	load(true)
 
	/** Gets list of offensive words found in dictionary. */
	const findBadWords = (textRunes) => {
		let outputBadWords = [],
			outputBadLetters = []
		badWords.forEach((word) => {
			const wordChunks = runes(word)
			for (let i = 0; i < textRunes.length; i++) {
				if (textRunes[i].toLowerCase() === wordChunks[0].toLowerCase()) {
					let matched = [i],
						wordIndex = 1,
						j = i + 1
					for (; j < textRunes.length && wordIndex < wordChunks.length; j++) {
						const checkLetter = textRunes[j].toLowerCase(),
							offensiveLetter = wordChunks[wordIndex].toLowerCase()
						if (checkLetter === offensiveLetter) {
							matched = matched.concat(j)
							wordIndex++
							continue
						}
						if (
							checkLetter === null ||
							(/[0-9]/.test(checkLetter) && !/^[0-9]+$/.test(word)) ||
							(!/[a-z0-9]/i.test(checkLetter) &&
								!/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/.test(
									checkLetter
								))
						) {
							// Ignore punctuation, spacing or unmatched numbers
							// Examples:
							// std = past 7 days
							// g00k = pog 100k
							continue
						}
						matched = []
						break
					}
					if (matched.length === wordChunks.length) {
						if (!outputBadWords.includes(word)) {
							outputBadWords = outputBadWords.concat(word)
						}
						for (const c of matched) {
							if (!outputBadLetters.includes(c)) {
								outputBadLetters = outputBadLetters.concat(c)
							}
						}
					}
				}
			}
		})
		return [outputBadWords, outputBadLetters]
	}
 
	return {
		loading,
		loadError,
		badWords,
		findBadWords,
		load,
	}
})(DICTIONARY_URL)
 
/** UI Interface that allows users to select a fragment of the MOTD to check for offensive words. */
const MOTDReviewUI = ((dictionary) => {
	const root = document.createElement("div"),
		textReview = document.createElement("div"),
		validationError = document.createElement("div"),
		badWordsError = document.createElement("div"),
		submitContainer = document.createElement("div")
	root.classList.add("nt-motd-reason", "empty")
 
	validationError.classList.add("nt-motd-reason-errors", "nt-motd-reason-error-validation")
	validationError.style.display = "none"
	validationError.innerHTML = `You must at select at least one text.`
 
	badWordsError.classList.add("nt-motd-reason-errors", "nt-motd-reason-error-bad-words")
	badWordsError.style.display = "none"
	badWordsError.innerHTML = `<p>The following words are possibly offensive:</p><ul></ul>`
 
	submitContainer.classList.add("nt-motd-reason-submit")
	submitContainer.innerHTML = `<small class="nt-motd-reason-helper">This script only has a limited set of offensive words.</small>
     <div><button class="btn btn--compact2 btn--xs btn--secondary" type="button" title="Refresh Dictionary">Refresh Dictionary</button></div>`
 
	const badWordsContainer = badWordsError.querySelector(".nt-motd-reason-error-bad-words ul"),
		refreshDictionaryButton = submitContainer.querySelector("button")
 
	let letters = [],
		letterNodes = [],
		badWords = []
 
	const reloadButtonClickHandler = () => {
		refreshDictionaryButton.disabled = true
		dictionary.load().then(() => {
			refreshDictionaryButton.disabled = false
 
			const [foundBadWords, badLetters] = dictionary.findBadWords(letters)
			textReview.querySelectorAll(".nt-motd-reason-letter").forEach((node) => {
				node.classList.remove("offensive")
			})
			badWords = foundBadWords
			badLetters.forEach((i) => {
				if (letterNodes[i]) {
					letterNodes[i].classList.add("offensive")
				}
			})
			refreshErrors(false)
		})
	}
	refreshDictionaryButton.addEventListener("click", reloadButtonClickHandler)
 
	// Output
	textReview.classList.add("nt-motd-reason-body")
 
	const refreshErrors = (invalidForm) => {
		if (!invalidForm && badWords.length === 0) {
			validationError.style.display = "none"
			badWordsError.style.display = "none"
			return
		}
		validationError.style.display = invalidForm ? "" : "none"
		badWordsError.style.display = badWords.length > 0 ? "" : "none"
 
		if (invalidForm) {
			badWordsError.classList.add("has-invalid-form")
		} else {
			badWordsError.classList.remove("has-invalid-form")
		}
 
		const badWordListTemplate = document.createElement("li"),
			fragment = document.createDocumentFragment()
		badWordListTemplate.classList.add("nt-motd-reason-error-item")
		while (badWordsContainer.firstChild) {
			badWordsContainer.removeChild(badWordsContainer.firstChild)
		}
		badWords.forEach((word) => {
			const node = badWordListTemplate.cloneNode()
			node.textContent = word
			fragment.append(node)
		})
		badWordsContainer.append(fragment)
	}
 
	const reset = (message) => {
		while (textReview.firstChild) {
			textReview.removeChild(textReview.firstChild)
		}
		if (!message) {
			root.classList.add("empty")
			return
		}
 
		badWords = []
		letterNodes = []
		letters = runes(message)
		refreshErrors(false)
 
		const [foundBadWords, badLetters] = dictionary.findBadWords(letters)
		badWords = foundBadWords
 
		const rootFragment = document.createDocumentFragment(),
			wordNodeTemplate = document.createElement("div"),
			letterNodeTemplate = document.createElement("div")
		wordNodeTemplate.classList.add("nt-motd-reason-word")
		letterNodeTemplate.classList.add("nt-motd-reason-letter")
 
		let wordFragment = document.createDocumentFragment(),
			wordFragmentEmpty = false
 
		for (let i = 0; i < letters.length; i++) {
			const char = letters[i]
			if (char === " ") {
				const wordRoot = wordNodeTemplate.cloneNode()
				wordRoot.append(wordFragment)
				rootFragment.append(wordRoot)
				wordFragment = document.createDocumentFragment()
				wordFragmentEmpty = true
				letterNodes = letterNodes.concat(null)
				continue
			}
			const letterNode = letterNodeTemplate.cloneNode()
			letterNode.textContent = char
			letterNode.dataset.letterindex = i
			if (badLetters.includes(i)) {
				letterNode.classList.add("offensive")
			}
 
			letterNodes = letterNodes.concat(letterNode)
			wordFragment.append(letterNode)
			wordFragmentEmpty = false
		}
		if (!wordFragmentEmpty) {
			const wordRoot = wordNodeTemplate.cloneNode()
			wordRoot.append(wordFragment)
			rootFragment.append(wordRoot)
		}
		refreshErrors(false)
		textReview.append(rootFragment)
		root.classList.remove("empty")
	}
	root.append(textReview, submitContainer, validationError, badWordsError)
 
	return {
		root,
		reset,
	}
})(OffensiveWordDictionary)
 
///////////////
//  Backend  //
///////////////
 
const errorObserver = new MutationObserver(([mutation]) => {
	if (mutation.addedNodes.length === 0) {
		return
	}
 
	const motd = document.querySelector(".modal textarea.input-field")
	if (!motd) {
		console.warn("Could not find MOTD input")
		return
	}
 
	const errorTextNode = mutation.addedNodes[0]
	if (!errorTextNode.textContent.startsWith("This contains words that are possibly offensive.")) {
		return
	}
	errorTextNode.textContent = "Message contains content that is possibly offensive. Let's review."
	MOTDReviewUI.reset(motd.value)
})
 
const modalObserver = new MutationObserver(([mutation]) => {
	for (const node of mutation.addedNodes) {
		if (node.classList?.contains("modal")) {
			errorObserver.observe(node.querySelector(".input-alert .bucket-content"), { childList: true })
			const form = node.querySelector("form"),
				target = node.querySelector("p.tss.tc-ts")
			if (!form || !target) {
				console.error("Failed to attach Word Reviewer")
				return
			}
			MOTDReviewUI.reset()
			form.insertBefore(MOTDReviewUI.root, target)
			break
		}
	}
})
 
modalObserver.observe(document.body, { childList: true })