-
Notifications
You must be signed in to change notification settings - Fork 58
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
New add-on. Declutterer is an add-on to remove some repetitive messages and help slow down spammy chats.
- Loading branch information
Showing
4 changed files
with
398 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
"use strict"; | ||
|
||
export const DEFAULT_SETTINGS = { | ||
enabled: false, | ||
similarity_threshold: 80, | ||
repetitions_threshold: 3, | ||
ignore_mods: true, | ||
force_enable_when_mod: false, | ||
cache_ttl: 30, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
"use strict"; | ||
const { createElement } = FrankerFaceZ.utilities.dom; | ||
import Logic from "./logic"; | ||
import { DEFAULT_SETTINGS } from "./constants"; | ||
|
||
/** | ||
* This index addon file is responsible for user settings and | ||
* enables/disables the chat filtering "logic" addon. | ||
*/ | ||
class Declutter extends Addon { | ||
constructor(...args) { | ||
super(...args); | ||
this.toggleEnabled = this.toggleEnabled.bind(this); | ||
this.inject("chat"); | ||
this.inject("settings"); | ||
this.inject("site.fine"); // this.fine | ||
this.inject("i18n"); | ||
this.register("logic", Logic, true); // name, module, inject_reference | ||
|
||
this.settings.add("addon.declutter.enabled", { | ||
default: DEFAULT_SETTINGS.enabled, | ||
ui: { | ||
path: "Add-Ons > Declutterer >> General Settings", | ||
title: "Enable by Default", | ||
description: | ||
"Enable add-on by default. Otherwise, enable the add-on per channel via the toggle button next to chat settings", | ||
component: "setting-check-box", | ||
}, | ||
}); | ||
|
||
this.settings.add("addon.declutter.similarity_threshold", { | ||
default: DEFAULT_SETTINGS.similarity_threshold, | ||
ui: { | ||
path: "Add-Ons > Declutterer >> Filter Settings", | ||
title: "Similarity threshold in %", | ||
description: | ||
"Minimum similarity between 2 messages to count them as repetitions", | ||
component: "setting-text-box", | ||
process: "to_int", | ||
bounds: [0, 100], | ||
}, | ||
}); | ||
|
||
this.settings.add("addon.declutter.repetitions_threshold", { | ||
default: DEFAULT_SETTINGS.repetitions_threshold, | ||
ui: { | ||
path: "Add-Ons > Declutterer >> Filter Settings", | ||
title: "Repetition threshold", | ||
description: "Amount of repetitions before the message is marked", | ||
component: "setting-text-box", | ||
process: "to_int", | ||
bounds: [0], | ||
}, | ||
}); | ||
|
||
this.settings.add("addon.declutter.ignore_mods", { | ||
default: DEFAULT_SETTINGS.ignore_mods, | ||
ui: { | ||
path: "Add-Ons > Declutterer >> Mod Settings", | ||
title: "Ignore mods", | ||
description: "Do not limit messages by mods or the broadcaster", | ||
component: "setting-check-box", | ||
}, | ||
}); | ||
|
||
this.settings.add("addon.declutter.force_enable_when_mod", { | ||
default: DEFAULT_SETTINGS.force_enable_when_mod, | ||
ui: { | ||
path: "Add-Ons > Declutterer >> Mod Settings", | ||
title: "Force enabled when Moderator", | ||
description: "Force filtering even in channels you are a moderator in", | ||
component: "setting-check-box", | ||
}, | ||
}); | ||
|
||
this.settings.add("addon.declutter.cache_ttl", { | ||
default: DEFAULT_SETTINGS.cache_ttl, | ||
ui: { | ||
path: "Add-Ons > Declutterer >> Cache Settings", | ||
title: "Cache TTL", | ||
description: | ||
"Amount of seconds for messages to stay in the cache. A long TTL leads to high RAM usage, especially in bigger channels", | ||
component: "setting-text-box", | ||
process: "to_int", | ||
bounds: [1], | ||
}, | ||
}); | ||
|
||
// workaround: logic starts enabled by default, which breaks checkEnabled() | ||
this.logic.disable(); | ||
|
||
this.set_enabled = null; | ||
this.ChatInput = this.fine.define("chat-input"); | ||
this.logic.on(":enabled", this.updateButtons, this); | ||
this.logic.on(":disabled", this.updateButtons, this); | ||
} | ||
|
||
onEnable() { | ||
this.chat.context.on( | ||
"changed:addon.declutter.enabled", | ||
this.checkEnabled, | ||
this | ||
); | ||
this.checkEnabled(); | ||
this.ChatInput.on("mount", this.updateButton, this); | ||
this.ChatInput.on("update", this.updateButton, this); | ||
this.updateButtons(); | ||
} | ||
|
||
async onDisable() { | ||
await this.logic.disable(); | ||
for (const inst of this.ChatInput.instances) { | ||
this.removeButton(inst); | ||
} | ||
this.ChatInput.off("mount", this.updateButton, this); | ||
this.ChatInput.off("update", this.updateButton, this); | ||
} | ||
|
||
checkEnabled() { | ||
const enabled = | ||
this.set_enabled ?? this.chat.context.get("addon.declutter.enabled"); | ||
if (enabled && !this.logic.enabled) { | ||
this.logic.enable(); | ||
} else if (!enabled && this.logic.enabled) { | ||
this.logic.disable(); | ||
} | ||
} | ||
|
||
updateButtons() { | ||
if (this.ChatInput) { | ||
if (this.enabling || this.enabled) { | ||
for (const inst of this.ChatInput.instances) { | ||
this.updateButton(inst); | ||
} | ||
} | ||
} | ||
} | ||
|
||
toggleEnabled() { | ||
this.set_enabled = !this.logic.enabled; | ||
this.checkEnabled(); | ||
} | ||
|
||
// Button tooltips piggyback on any PrattleNot internationalisation text | ||
updateButton(inst) { | ||
const node = this.fine.getHostNode(inst); | ||
if (!node) { | ||
return; | ||
} | ||
if (!inst._ffz_declutter_button) { | ||
inst._ffz_declutter_button = ( | ||
<div class="tw-relative ffz-il-tooltip__container"> | ||
<button | ||
class={`tw-border-radius-medium tw-button-icon--primary ffz-core-button | ||
tw-inline-flex tw-interactive tw-justify-content-center | ||
tw-overflow-hidden tw-relative tw-button-icon`} | ||
onclick={this.toggleEnabled} | ||
> | ||
<span class="tw-button-icon__icon"> | ||
{(inst._ffz_declutter_icon = <figure class="ffz-i-zreknarf" />)} | ||
</span> | ||
</button> | ||
<div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right"> | ||
{this.i18n | ||
.t("addon.pn.button.title", "Toggle Declutterer") | ||
.replace("PrattleNot", "Declutterer")} | ||
{(inst._ffz_declutter_enabled_tip = <div></div>)} | ||
</div> | ||
</div> | ||
); | ||
} | ||
if (!node.contains(inst._ffz_declutter_button)) { | ||
const container = node.querySelector( | ||
".chat-input__buttons-container > div:last-child" | ||
); | ||
if (container) { | ||
container.insertBefore( | ||
inst._ffz_declutter_button, | ||
container.firstChild | ||
); | ||
} | ||
} | ||
inst._ffz_declutter_icon.className = this.logic.enabled | ||
? "ffz-i-chat" | ||
: "ffz-i-chat-empty"; | ||
inst._ffz_declutter_enabled_tip.classList.toggle( | ||
"tw-mg-t-1", | ||
this.logic.enabled | ||
); | ||
inst._ffz_declutter_enabled_tip.textContent = this.logic.enabled | ||
? this.i18n | ||
.t( | ||
"addon.pn.button.enabled", | ||
"Declutterer is currently enabled. Click to disable." | ||
) | ||
.replace("PrattleNot", "Declutterer") | ||
: null; | ||
} | ||
|
||
removeButton(inst) { | ||
if (inst._ffz_declutter_button) { | ||
inst._ffz_declutter_button.remove(); | ||
inst._ffz_declutter_button = null; | ||
inst._ffz_declutter_enabled_tip = null; | ||
} | ||
} | ||
} | ||
|
||
Declutter.register(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
"use strict"; | ||
import { DEFAULT_SETTINGS } from "./constants"; | ||
|
||
/** | ||
* This addon is toggled on/off by the index addon (or enabled by default via setting), | ||
* and performs the actual chat filtering. | ||
*/ | ||
export default class Logic extends Addon { | ||
cache = new Map(); // message -> [expiry timestamp] | ||
cacheEvictionTimer; | ||
chatContext; | ||
RepetitionCounter; | ||
userId; | ||
|
||
constructor(...args) { | ||
super(...args); | ||
this.inject("chat"); | ||
this.inject("settings"); | ||
this.inject("site"); | ||
this.chatContext = this.chat.context; | ||
this.userId = this.site?.getUser()?.id; | ||
this.cacheTtl = | ||
(this.settings.get("addon.declutter.cache_ttl") ?? | ||
DEFAULT_SETTINGS.cache_ttl) * 1000; | ||
} | ||
|
||
/** | ||
* Calculates the degree of similarity of 2 strings based on Dices Coefficient | ||
* @param {string} first First string for comparison | ||
* @param {string} second Second string for comparison | ||
* @returns {number} Degree of similarity in the range [0,1] | ||
* @see Original source code {@link https://github.com/aceakash/string-similarity}, MIT License | ||
*/ | ||
compareTwoStrings = (first, second) => { | ||
first = first.replace(/\s+/g, ""); | ||
second = second.replace(/\s+/g, ""); | ||
if (!first.length && !second.length) return 1; | ||
if (!first.length || !second.length) return 0; | ||
if (first === second) return 1; | ||
if (first.length === 1 && second.length === 1) { | ||
return first === second ? 1 : 0; | ||
} | ||
if (first.length < 2 || second.length < 2) return 0; | ||
const firstBigrams = new Map(); | ||
for (let i = 0; i < first.length - 1; i++) { | ||
const bigram = first.substring(i, i + 2); | ||
const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) + 1 : 1; | ||
firstBigrams.set(bigram, count); | ||
} | ||
let intersectionSize = 0; | ||
for (let i = 0; i < second.length - 1; i++) { | ||
const bigram = second.substring(i, i + 2); | ||
const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) : 0; | ||
if (count > 0) { | ||
firstBigrams.set(bigram, count - 1); | ||
intersectionSize++; | ||
} | ||
} | ||
return (2.0 * intersectionSize) / (first.length + second.length - 2); | ||
}; | ||
|
||
checkRepetitionAndCache = (message) => { | ||
const simThreshold = | ||
this.settings.get("addon.declutter.similarity_threshold") ?? | ||
DEFAULT_SETTINGS.similarity_threshold; | ||
const repThreshold = | ||
this.settings.get("addon.declutter.repetitions_threshold") ?? | ||
DEFAULT_SETTINGS.repetitions_threshold; | ||
let n = 0; | ||
for (const [msg, timestamps] of this.cache) { | ||
if (simThreshold === 100) { | ||
if (message.toLowerCase() === msg.toLowerCase()) { | ||
n += timestamps?.length ?? 1; | ||
} | ||
} else if ( | ||
this.compareTwoStrings(message.toLowerCase(), msg.toLowerCase()) > | ||
simThreshold / 100 | ||
) { | ||
n += timestamps?.length ?? 1; | ||
} | ||
if (n >= repThreshold) { | ||
break; | ||
} | ||
} | ||
const existing = this.cache.get(message) ?? []; | ||
this.cache.set(message, [...existing, Date.now() + this.cacheTtl]); | ||
this.log.debug("(" + n + "): " + message); | ||
return n; | ||
}; | ||
|
||
onEnable = () => { | ||
this.log.debug("Enabling Declutterer"); | ||
this.updateConstants(); | ||
this.chat.context.on( | ||
"changed:addon.declutter.cache_ttl", | ||
this.updateConstants, | ||
this | ||
); | ||
this.on("chat:receive-message", this.handleMessage, this); | ||
}; | ||
|
||
updateConstants = () => { | ||
// Note any new value for cache TTL won't be picked up until re-enable | ||
this.cache_ttl = this.chat.context.get("addon.declutter.cache_ttl"); | ||
// Cache eviction will happen 10x per TTL, at least once every 10s, max once per second | ||
this.startCacheEvictionTimer( | ||
Math.min(Math.max(1, Math.floor(this.cache_ttl / 10)), 10) | ||
); | ||
}; | ||
|
||
onDisable = () => { | ||
this.log.debug("Disabling Declutterer"); | ||
this.off("chat:receive-message", this.handleMessage, this); | ||
if (this.cacheEvictionTimer) { | ||
clearInterval(this.cacheEvictionTimer); | ||
} | ||
this.cache = new Map(); | ||
}; | ||
|
||
startCacheEvictionTimer = (intervalSeconds) => { | ||
if (this.cacheEvictionTimer) { | ||
clearInterval(this.cacheEvictionTimer); | ||
} | ||
this.cacheEvictionTimer = setInterval(() => { | ||
this.log.debug("Running cache eviction cycle"); | ||
for (const [msg, timestamps] of this.cache) { | ||
const futureTimestamps = | ||
timestamps?.filter((time) => time > Date.now()) ?? []; | ||
if (futureTimestamps.length === 0) { | ||
this.cache.delete(msg); | ||
} else { | ||
this.cache.set(msg, futureTimestamps); | ||
} | ||
} | ||
}, intervalSeconds * 1000); | ||
}; | ||
|
||
handleMessage = (event) => { | ||
if (!event.message || event.defaultPrevented) return; | ||
if ( | ||
this.chatContext && | ||
this.chatContext.get("context.moderator") && | ||
!this.settings.get("addon.declutter.force_enable_when_mod") | ||
) | ||
return; | ||
const msg = event.message; | ||
if (msg.ffz_removed || msg.deleted || !msg.ffz_tokens) return; | ||
if ( | ||
this.settings.get("addon.declutter.ignore_mods") && | ||
(msg.badges.moderator || msg.badges.broadcaster) | ||
) | ||
return; | ||
if (msg.user && this.userId && msg.user.id == this.userId) { | ||
// Always show the user's own messages | ||
return; | ||
} | ||
if (!msg.repetitionCount && msg.repetitionCount !== 0) { | ||
msg.repetitionCount = this.checkRepetitionAndCache(msg.message); | ||
} | ||
const repThreshold = | ||
this.settings.get("addon.declutter.repetitions_threshold") ?? | ||
DEFAULT_SETTINGS.repetitions_threshold; | ||
if (msg.repetitionCount >= repThreshold) { | ||
// Hide messages matching our filter | ||
event.preventDefault(); | ||
} | ||
}; | ||
} |
Oops, something went wrong.