From 46fc75f7d3b4ffc077d66fe8b9a2b7d764743a39 Mon Sep 17 00:00:00 2001 From: Farhad Jay Date: Thu, 7 May 2026 11:09:00 -0700 Subject: [PATCH] Allow trusted-flair users to bypass link muting Add a configurable trustedFlairs list to the !mutelinks command so users with the trusted flair (flair4 by default) can post links without being muted across all link-muting modes (on, all, repeat). --- lib/commands/implementations/mutelinks.js | 26 ++- lib/configuration/configure-commands.js | 3 +- lib/configuration/sample.config.json | 3 + .../implementations/mutelinks.test.js | 191 +++++++++++++++++- 4 files changed, 205 insertions(+), 18 deletions(-) diff --git a/lib/commands/implementations/mutelinks.js b/lib/commands/implementations/mutelinks.js index fe7f97b..69d1cc1 100644 --- a/lib/commands/implementations/mutelinks.js +++ b/lib/commands/implementations/mutelinks.js @@ -8,7 +8,7 @@ const { makeMute } = require('../../chat-utils/punishment-helpers'); const formatDuration = require('../../chat-utils/format-duration'); const normalizeUrl = require('../../chat-utils/normalize-url'); -function mutelinks(defaultPunishmentDuration) { +function mutelinksFactory(defaultPunishmentDuration, trustedFlairs = []) { // States: "on" | "off" | "all" | "repeated" let state = 'off'; // The user to be mentioned, if null then don't care about mention @@ -16,6 +16,12 @@ function mutelinks(defaultPunishmentDuration) { let muteDuration = null; + const isTrustedUser = (data) => { + if (!trustedFlairs.length) return false; + const roles = data.roles || []; + return roles.some((role) => trustedFlairs.includes(role)); + }; + return (input, services, rawMessage) => { const matched = /(on|off|all|repeat(?:ed)?)(?:(?:\s+)(\d+[HMDSWwhmds]))?/.exec(input); const newState = _.get(matched, 1, ''); @@ -58,6 +64,7 @@ function mutelinks(defaultPunishmentDuration) { const message = data.message.trim().toLowerCase(); if (!services.messageMatching.mentionsUser(message, mentionUser)) return; if (!services.messageMatching.hasLink(message)) return; + if (isTrustedUser(data)) return; services.punishmentStream.write( makeMute( @@ -71,6 +78,7 @@ function mutelinks(defaultPunishmentDuration) { listener.on('msg', (data) => { const message = data.message.trim().toLowerCase(); if (!services.messageMatching.hasLink(message)) return; + if (isTrustedUser(data)) return; services.punishmentStream.write( makeMute( @@ -84,6 +92,7 @@ function mutelinks(defaultPunishmentDuration) { listener.on('msg', (data) => { const message = data.message.trim().toLowerCase(); if (!services.messageMatching.hasLink(message)) return; + if (isTrustedUser(data)) return; const links = services.messageMatching.getLinks(message); const recentUrls = services.chatCache.getRecentUrls(); @@ -116,11 +125,12 @@ function mutelinks(defaultPunishmentDuration) { } module.exports = { - mutelinks: new Command( - mutelinks(60), - false, - true, - /(on|off|all|repeat(?:ed)?)(?:(?:\s+)(\d+[HMDSWwhmds]))?/, - false, - ), + mutelinks: (defaultPunishmentDuration, trustedFlairs) => + new Command( + mutelinksFactory(defaultPunishmentDuration, trustedFlairs), + false, + true, + /(on|off|all|repeat(?:ed)?)(?:(?:\s+)(\d+[HMDSWwhmds]))?/, + false, + ), }; diff --git a/lib/configuration/configure-commands.js b/lib/configuration/configure-commands.js index e9ddf6e..00d78a0 100644 --- a/lib/configuration/configure-commands.js +++ b/lib/configuration/configure-commands.js @@ -76,7 +76,8 @@ function registerCommandsFromFiles(commandRegistry, chatConnectedTo, config) { commandRegistry.registerCommand('!setdeaths', setDeaths, ['!setd', '!sdeaths']); commandRegistry.registerCommand('!incdeaths', incrementDeaths, ['!ideaths', '!incd', '!id']); commandRegistry.registerCommand('!deaths', getDeaths, ['!death', '!died']); - commandRegistry.registerCommand('!mutelinks', mutelinks, [ + const mutelinksTrustedFlairs = (config.mutelinks && config.mutelinks.trustedFlairs) || []; + commandRegistry.registerCommand('!mutelinks', mutelinks(60, mutelinksTrustedFlairs), [ '!mutelink', '!linkmute', '!linksmute', diff --git a/lib/configuration/sample.config.json b/lib/configuration/sample.config.json index 90c8a30..06db62f 100644 --- a/lib/configuration/sample.config.json +++ b/lib/configuration/sample.config.json @@ -27,6 +27,9 @@ "messageUrlSpamUser": "Destiny", "urlTtlSeconds": 600 }, + "mutelinks": { + "trustedFlairs": ["flair4"] + }, "punishmentCache": { "baseMuteSeconds": 60, "muteGrowthMultiplier": 2, diff --git a/tests/lib/commands/implementations/mutelinks.test.js b/tests/lib/commands/implementations/mutelinks.test.js index 97ac3ad..8741495 100644 --- a/tests/lib/commands/implementations/mutelinks.test.js +++ b/tests/lib/commands/implementations/mutelinks.test.js @@ -21,13 +21,14 @@ describe('Mutelinks Test', () => { error: () => {}, }, }; + this.mutelinks = mutelinks(60, []); }); it('mutes link messages when "all" with default time, then turned off', function () { const messageRelay = this.mockServices.messageRelay; const punishmentStream = this.mockServices.punishmentStream; - const output1 = mutelinks.work('all', this.mockServices); + const output1 = this.mutelinks.work('all', this.mockServices); assert.deepStrictEqual( output1, new CommandOutput(null, 'Link muting (1m) turned on for all links'), @@ -46,7 +47,7 @@ describe('Mutelinks Test', () => { user: 'test1', }); - const output2 = mutelinks.work('off', this.mockServices); + const output2 = this.mutelinks.work('off', this.mockServices); assert.deepStrictEqual(output2, new CommandOutput(null, 'Link muting turned off')); messageRelay.relayMessageToListeners('msg', { @@ -68,7 +69,7 @@ describe('Mutelinks Test', () => { const messageRelay = this.mockServices.messageRelay; const punishmentStream = this.mockServices.punishmentStream; - const output1 = mutelinks.work('on 20m', this.mockServices, { user: 'deStInY' }); + const output1 = this.mutelinks.work('on 20m', this.mockServices, { user: 'deStInY' }); assert.deepStrictEqual( output1, new CommandOutput(null, 'Link muting (20m) turned on for mentioning deStInY'), @@ -111,7 +112,7 @@ describe('Mutelinks Test', () => { user: 'test8', }); - const output2 = mutelinks.work('off', this.mockServices); + const output2 = this.mutelinks.work('off', this.mockServices); assert.deepStrictEqual(output2, new CommandOutput(null, 'Link muting turned off')); messageRelay.relayMessageToListeners('msg', { @@ -137,22 +138,22 @@ describe('Mutelinks Test', () => { const messageRelay = this.mockServices.messageRelay; const punishmentStream = this.mockServices.punishmentStream; - const output1 = mutelinks.work('on', this.mockServices, { user: 'deStInY' }); + const output1 = this.mutelinks.work('on', this.mockServices, { user: 'deStInY' }); assert.deepStrictEqual( output1, new CommandOutput(null, 'Link muting (1m) turned on for mentioning deStInY'), ); - const output2 = mutelinks.work('on 1m', this.mockServices, { user: 'deStInY' }); + const output2 = this.mutelinks.work('on 1m', this.mockServices, { user: 'deStInY' }); assert.deepStrictEqual( output2, new CommandOutput(null, 'Link muting (1m) is already on for mentioning deStInY'), ); - const output3 = mutelinks.work('on 20m', this.mockServices, { user: 'deStInY' }); + const output3 = this.mutelinks.work('on 20m', this.mockServices, { user: 'deStInY' }); assert.deepStrictEqual( output3, new CommandOutput(null, 'Link muting (20m) turned on for mentioning deStInY'), ); - const output4 = mutelinks.work('all 20m', this.mockServices, { user: 'deStInY' }); + const output4 = this.mutelinks.work('all 20m', this.mockServices, { user: 'deStInY' }); assert.deepStrictEqual( output4, new CommandOutput(null, 'Link muting (20m) turned on for all links'), @@ -169,7 +170,7 @@ describe('Mutelinks Test', () => { normalizeUrl: (url) => (url.hostname + url.pathname).toLowerCase(), }; - const output1 = mutelinks.work('repeat 15m', this.mockServices); + const output1 = this.mutelinks.work('repeat 15m', this.mockServices); assert.deepStrictEqual(output1, new CommandOutput(null, 'Link muting (15m) turned repeat')); // First message with a new link - should not be muted @@ -206,4 +207,176 @@ describe('Mutelinks Test', () => { makeMute('test3', 900, 'test3 muted for 15m for posting a repeated link.'), ); }); + + it('does not mute trusted-flair users in "all" mode but still mutes others', function () { + const messageRelay = this.mockServices.messageRelay; + const punishmentStream = this.mockServices.punishmentStream; + const trusted = mutelinks(60, ['flair4']); + + trusted.work('all', this.mockServices); + + messageRelay.relayMessageToListeners('msg', { + message: 'check this out https://twitch.tv', + user: 'trustedUser', + roles: ['flair4'], + }); + messageRelay.relayMessageToListeners('msg', { + message: 'click https://youtube.com', + user: 'untrustedUser', + roles: ['flair9'], + }); + messageRelay.relayMessageToListeners('msg', { + message: 'no roles at all https://reddit.com', + user: 'noRolesUser', + }); + + assert.deepStrictEqual(punishmentStream.write.callCount, 2); + assert.deepStrictEqual( + punishmentStream.write.getCall(0).args[0], + makeMute( + 'untrustedUser', + 60, + 'untrustedUser muted for 1m for posting a link while link muting is on.', + ), + ); + assert.deepStrictEqual( + punishmentStream.write.getCall(1).args[0], + makeMute( + 'noRolesUser', + 60, + 'noRolesUser muted for 1m for posting a link while link muting is on.', + ), + ); + }); + + it('does not mute trusted-flair users in "on" (mention) mode', function () { + const messageRelay = this.mockServices.messageRelay; + const punishmentStream = this.mockServices.punishmentStream; + const trusted = mutelinks(60, ['flair4']); + + trusted.work('on', this.mockServices, { user: 'deStInY' }); + + messageRelay.relayMessageToListeners('msg', { + message: 'destiny click https://twitch.tv', + user: 'trustedUser', + roles: ['flair4'], + }); + messageRelay.relayMessageToListeners('msg', { + message: 'destiny click https://youtube.com', + user: 'untrustedUser', + roles: ['flair9'], + }); + + assert.deepStrictEqual(punishmentStream.write.callCount, 1); + assert.deepStrictEqual( + punishmentStream.write.getCall(0).args[0], + makeMute('untrustedUser', 60, 'untrustedUser muted for 1m for tagging deStInY with a link.'), + ); + }); + + it('does not mute trusted-flair users in "repeat" mode', function () { + const messageRelay = this.mockServices.messageRelay; + const punishmentStream = this.mockServices.punishmentStream; + this.mockServices.chatCache = { + getRecentUrls: sinon.stub().returns(['twitch.tv/', 'youtube.com/']), + }; + const trusted = mutelinks(60, ['flair4']); + + trusted.work('repeat 15m', this.mockServices); + + messageRelay.relayMessageToListeners('msg', { + message: 'hey check this out https://twitch.tv', + user: 'trustedUser', + roles: ['flair4'], + }); + messageRelay.relayMessageToListeners('msg', { + message: 'cool video https://youtube.com', + user: 'untrustedUser', + roles: ['flair9'], + }); + + assert.deepStrictEqual(punishmentStream.write.callCount, 1); + assert.deepStrictEqual( + punishmentStream.write.getCall(0).args[0], + makeMute('untrustedUser', 900, 'untrustedUser muted for 15m for posting a repeated link.'), + ); + }); + + it('uses the configured trusted flair identifier (not hardcoded to flair4)', function () { + const messageRelay = this.mockServices.messageRelay; + const punishmentStream = this.mockServices.punishmentStream; + const trusted = mutelinks(60, ['flair9']); + + trusted.work('all', this.mockServices); + + messageRelay.relayMessageToListeners('msg', { + message: 'click https://twitch.tv', + user: 'oldTrusted', + roles: ['flair4'], + }); + messageRelay.relayMessageToListeners('msg', { + message: 'click https://youtube.com', + user: 'newTrusted', + roles: ['flair9'], + }); + + assert.deepStrictEqual(punishmentStream.write.callCount, 1); + assert.deepStrictEqual( + punishmentStream.write.getCall(0).args[0], + makeMute('oldTrusted', 60, 'oldTrusted muted for 1m for posting a link while link muting is on.'), + ); + }); + + it('mutes everyone when trusted flair list is empty (default behavior)', function () { + const messageRelay = this.mockServices.messageRelay; + const punishmentStream = this.mockServices.punishmentStream; + + this.mutelinks.work('all', this.mockServices); + + messageRelay.relayMessageToListeners('msg', { + message: 'click https://twitch.tv', + user: 'wouldBeTrusted', + roles: ['flair4'], + }); + + assert.deepStrictEqual(punishmentStream.write.callCount, 1); + assert.deepStrictEqual( + punishmentStream.write.getCall(0).args[0], + makeMute( + 'wouldBeTrusted', + 60, + 'wouldBeTrusted muted for 1m for posting a link while link muting is on.', + ), + ); + }); + + it('accepts multiple trusted flair identifiers', function () { + const messageRelay = this.mockServices.messageRelay; + const punishmentStream = this.mockServices.punishmentStream; + const trusted = mutelinks(60, ['flair4', 'flair9']); + + trusted.work('all', this.mockServices); + + messageRelay.relayMessageToListeners('msg', { + message: 'click https://twitch.tv', + user: 'trustedA', + roles: ['flair4'], + }); + messageRelay.relayMessageToListeners('msg', { + message: 'click https://youtube.com', + user: 'trustedB', + roles: ['flair9'], + }); + messageRelay.relayMessageToListeners('msg', { + message: 'click https://reddit.com', + user: 'untrusted', + roles: ['flair3'], + }); + + assert.deepStrictEqual(punishmentStream.write.callCount, 1); + assert.deepStrictEqual( + punishmentStream.write.getCall(0).args[0], + makeMute('untrusted', 60, 'untrusted muted for 1m for posting a link while link muting is on.'), + ); + }); });