diff --git a/backend/chat/chat-listeners/listeners/chat-message-streamer-listener.js b/backend/chat/chat-listeners/listeners/chat-message-streamer-listener.js index 9e78cf855..3b3a9da25 100644 --- a/backend/chat/chat-listeners/listeners/chat-message-streamer-listener.js +++ b/backend/chat/chat-listeners/listeners/chat-message-streamer-listener.js @@ -14,7 +14,7 @@ module.exports = { const timerManager = require("../../../timers/timer-manager"); //Send to chat moderation service - chatModerationManager.moderateMessage(data); + await chatModerationManager.moderateMessage(data); // Send to command router to see if we need to act on a command. commandHandler.handleChatEvent(data).catch(reason => { diff --git a/backend/chat/chat-listeners/twitch-chat-listeners.js b/backend/chat/chat-listeners/twitch-chat-listeners.js index 1708fae13..daa0498c4 100644 --- a/backend/chat/chat-listeners/twitch-chat-listeners.js +++ b/backend/chat/chat-listeners/twitch-chat-listeners.js @@ -28,7 +28,7 @@ exports.setupChatListeners = (streamerChatClient) => { streamerChatClient.onPrivmsg(async (_channel, user, messageText, msg) => { const firebotChatMessage = await chatHelpers.buildFirebotChatMessage(msg, messageText); - chatModerationManager.moderateMessage(firebotChatMessage); + await chatModerationManager.moderateMessage(firebotChatMessage); // send to the frontend if (firebotChatMessage.isHighlighted) { diff --git a/backend/chat/moderation/chat-moderation-manager.js b/backend/chat/moderation/chat-moderation-manager.js index 3053f1634..a2904e09a 100644 --- a/backend/chat/moderation/chat-moderation-manager.js +++ b/backend/chat/moderation/chat-moderation-manager.js @@ -4,6 +4,7 @@ const profileManager = require("../../common/profile-manager"); const { Worker } = require("worker_threads"); const frontendCommunicator = require("../../common/frontend-communicator"); const rolesManager = require("../../roles/custom-roles-manager"); +const permitCommand = require("./url-permit-command"); let getChatModerationSettingsDb = () => profileManager.getJsonDbInProfile("/chat/moderation/chat-moderation-settings"); let getBannedWordsDb = () => profileManager.getJsonDbInProfile("/chat/moderation/banned-words", false); @@ -17,6 +18,14 @@ let chatModerationSettings = { enabled: false, max: 10 }, + urlModeration: { + enabled: false, + viewTime: { + enabled: false, + viewTimeInHours: 0 + }, + outputMessage: "" + }, exemptRoles: [] }; @@ -98,11 +107,14 @@ const countEmojis = (str) => { * * @param {import("../chat-helpers").FirebotChatMessage} chatMessage */ -function moderateMessage(chatMessage) { +async function moderateMessage(chatMessage) { if (chatMessage == null) return; - if (!chatModerationSettings.bannedWordList.enabled - && !chatModerationSettings.emoteLimit.enabled) return; + if ( + !chatModerationSettings.bannedWordList.enabled + && !chatModerationSettings.emoteLimit.enabled + && !chatModerationSettings.urlModeration.enabled + ) return; let moderateMessage = false; @@ -114,6 +126,7 @@ function moderateMessage(chatMessage) { } if (moderateMessage) { + const chat = require("../twitch-chat"); if (chatModerationSettings.emoteLimit.enabled && !!chatModerationSettings.emoteLimit.max) { const emoteCount = chatMessage.parts.filter(p => p.type === "emote").length; @@ -121,12 +134,47 @@ function moderateMessage(chatMessage) { .filter(p => p.type === "text") .reduce((acc, part) => acc + countEmojis(part.text), 0); if ((emoteCount + emojiCount) > chatModerationSettings.emoteLimit.max) { - const chat = require("../twitch-chat"); chat.deleteMessage(chatMessage.id); return; } } + if (chatModerationSettings.urlModeration.enabled) { + if (permitCommand.hasTemporaryPermission(chatMessage.username)) return; + + const message = chatMessage.rawText; + const regex = new RegExp(/[\w][.][a-zA-Z]/, "gi"); + + if (!regex.test(message)) return; + + logger.debug("Url moderation: Found url in message..."); + + const settings = chatModerationSettings.urlModeration; + let outputMessage = settings.outputMessage || ""; + + if (settings.viewTime && settings.viewTime.enabled) { + const viewerDB = require('../../database/userDatabase'); + const viewer = await viewerDB.getUserByUsername(chatMessage.username); + + const viewerViewTime = viewer.minutesInChannel / 60; + const minimumViewTime = settings.viewTime.viewTimeInHours; + + if (viewerViewTime > minimumViewTime) return; + + outputMessage = outputMessage.replace("{viewTime}", minimumViewTime.toString()); + + logger.debug("Url moderation: Not enough view time."); + } else { + logger.debug("Url moderation: User does not have exempt role."); + } + + chat.deleteMessage(chatMessage.id); + + if (outputMessage) { + outputMessage = outputMessage.replace("{userName}", chatMessage.username); + chat.sendChatMessage(outputMessage); + } + } const message = chatMessage.rawText; const messageId = chatMessage.id; @@ -204,6 +252,21 @@ function load() { if (settings.emoteLimit == null) { settings.emoteLimit = { enabled: false, max: 10 }; } + + if (settings.urlModeration == null) { + settings.urlModeration = { + enabled: false, + viewTime: { + enabled: false, + viewTimeInHours: 0 + }, + outputMessage: "" + }; + } + + if (settings.urlModeration.enabled) { + permitCommand.registerPermitCommand(); + } } let words = getBannedWordsDb().getData("/"); diff --git a/backend/chat/moderation/url-permit-command.js b/backend/chat/moderation/url-permit-command.js new file mode 100644 index 000000000..36aa5cb16 --- /dev/null +++ b/backend/chat/moderation/url-permit-command.js @@ -0,0 +1,101 @@ +"use strict"; + +const logger = require("../../logwrapper"); +const commandManager = require("../commands/CommandManager"); +const frontendCommunicator = require("../../common/frontend-communicator"); + +const PERMIT_COMMAND_ID = "firebot:moderation:url:permit"; +let tempPermittedUsers = []; + +const permitCommand = { + definition: { + id: PERMIT_COMMAND_ID, + name: "Permit", + active: true, + trigger: "!permit", + usage: "[target]", + description: "Permits a viewer to post a url for a set duration (see Moderation -> Url Moderation).", + autoDeleteTrigger: false, + scanWholeMessage: false, + hideCooldowns: true, + restrictionData: { + restrictions: [ + { + id: "sys-cmd-mods-only-perms", + type: "firebot:permissions", + mode: "roles", + roleIds: [ + "broadcaster", + "mod" + ] + } + ] + }, + options: { + permitDuration: { + type: "number", + title: "Duration in seconds", + default: 30, + description: "The amount of time the viewer has to post a link after the !permit command is used." + }, + permitDisplayTemplate: { + type: "string", + title: "Output Template", + description: "The chat message shown when the permit command is used (leave empty for no message).", + tip: "Variables: {target}, {duration}", + default: `{target}, you have {duration} seconds to post your url in the chat.`, + useTextArea: true + } + } + }, + onTriggerEvent: async event => { + const twitchChat = require("../twitch-chat"); + const { commandOptions } = event; + const target = event.userCommand.args[0]; + + if (!target) { + twitchChat.sendChatMessage("Please specify a user to permit."); + return; + } + + tempPermittedUsers.push(target); + logger.debug(`Url moderation: ${target} has been temporary permitted to post a url...`); + + const message = commandOptions.permitDisplayTemplate.replace("{target}", target).replace("{duration}", commandOptions.permitDuration); + + if (message) { + twitchChat.sendChatMessage(message); + } + + setTimeout(() => { + tempPermittedUsers = tempPermittedUsers.filter(user => user !== target); + logger.debug(`Url moderation: Temporary url permission for ${target} expired.`); + }, commandOptions.permitDuration * 1000); + } +}; + +function hasTemporaryPermission(username) { + return tempPermittedUsers.includes(username); +} + +function registerPermitCommand() { + if (!commandManager.hasSystemCommand(PERMIT_COMMAND_ID)) { + commandManager.registerSystemCommand(permitCommand); + } +} + +function unregisterPermitCommand() { + commandManager.unregisterSystemCommand(PERMIT_COMMAND_ID); +} + +frontendCommunicator.on("registerPermitCommand", () => { + registerPermitCommand(); +}); + +frontendCommunicator.on("unregisterPermitCommand", () => { + unregisterPermitCommand(); +}); + +exports.hasTemporaryPermission = hasTemporaryPermission; +exports.registerPermitCommand = registerPermitCommand; +exports.unregisterPermitCommand = unregisterPermitCommand; \ No newline at end of file diff --git a/gui/app/controllers/moderation.controller.js b/gui/app/controllers/moderation.controller.js index d0c94516d..fc3fdcf20 100644 --- a/gui/app/controllers/moderation.controller.js +++ b/gui/app/controllers/moderation.controller.js @@ -29,7 +29,8 @@ $scope.getExemptRoles = () => { return [ ...viewerRolesService.getTwitchRoles(), - ...viewerRolesService.getCustomRoles() + ...viewerRolesService.getCustomRoles(), + ...viewerRolesService.getTeamRoles() ].filter(r => chatModerationService.chatModerationData.settings.exemptRoles.includes(r.id)); }; @@ -38,7 +39,8 @@ const options = [ ...viewerRolesService.getTwitchRoles(), - ...viewerRolesService.getCustomRoles() + ...viewerRolesService.getCustomRoles(), + ...viewerRolesService.getTeamRoles() ] .filter(r => !chatModerationService.chatModerationData.settings.exemptRoles.includes(r.id)) @@ -69,9 +71,15 @@ $scope.cms = chatModerationService; - $scope.toggleBannedWordsFeature = () => { - chatModerationService.chatModerationData.settings.bannedWordList.enabled = - !chatModerationService.chatModerationData.settings.bannedWordList.enabled; + $scope.toggleUrlModerationFeature = () => { + if (!chatModerationService.chatModerationData.settings.urlModeration.enabled) { + chatModerationService.chatModerationData.settings.urlModeration.enabled = true; + chatModerationService.registerPermitCommand(); + } else { + chatModerationService.chatModerationData.settings.urlModeration.enabled = false; + chatModerationService.unregisterPermitCommand(); + } + chatModerationService.saveChatModerationSettings(); }; diff --git a/gui/app/services/chat-moderation.service.js b/gui/app/services/chat-moderation.service.js index 7aa0f666f..c5a67200a 100644 --- a/gui/app/services/chat-moderation.service.js +++ b/gui/app/services/chat-moderation.service.js @@ -18,6 +18,14 @@ enabled: false, max: 10 }, + urlModeration: { + enabled: false, + viewTime: { + enabled: false, + viewTimeInHours: 0 + }, + outputMessage: "" + }, exemptRoles: [] }, bannedWords: [] @@ -81,6 +89,14 @@ backendCommunicator.fireEvent("removeAllBannedWords"); }; + service.registerPermitCommand = () => { + backendCommunicator.fireEvent("registerPermitCommand"); + }; + + service.unregisterPermitCommand = () => { + backendCommunicator.fireEvent("unregisterPermitCommand"); + }; + return service; }); }()); diff --git a/gui/app/templates/_moderation.html b/gui/app/templates/_moderation.html index cd1abd76d..a83258bcd 100644 --- a/gui/app/templates/_moderation.html +++ b/gui/app/templates/_moderation.html @@ -16,68 +16,140 @@
-
+
Exempt Roles
- {{role.name}} - - - + {{role.name}} + + +
- +
-
-
-
-
Banned Word List
-
- -
+
+
+
+
Banned Word List
+
+
+
-
-
- Words -
- -
-
+
+
+ Words +
+ +
+
-
-
-
-
Emote/Emoji Limit
-
- -
+
+
+
+
Emote/Emoji Limit
+
+
+
+ +
+ +
+
+
+ +
+
+
+
Url Moderation
+
+ +
+
+
+ A !permit command is automatically added to System Commands. +
-
+
+
View Time
+
+ +
+ +
+
All viewers with a higher view time will be exempt from url moderation.
-
+
+ +
+
The chat message shown when a message containing a url is moderated (leave empty for no message).
+
+ Variables: {userName}, {viewTime} +
+ +
+
diff --git a/gui/scss/core/_moderation.scss b/gui/scss/core/_moderation.scss index d28fc7fd7..65734f051 100644 --- a/gui/scss/core/_moderation.scss +++ b/gui/scss/core/_moderation.scss @@ -25,4 +25,36 @@ margin:0px; padding:0px; } -} \ No newline at end of file +} + +.moderation-feature-block { + width: 50%; + + &:not(:first-child) { + margin-top: 15px; + } + + .title-toggle-button-container { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + + .moderation-feature-title { + font-weight: 600; + font-size: 20px; + } + + .moderation-feature-subtitle { + font-weight: 600; + font-size: 16px; + } + + .toggle-button-container { + width: 150px; + flex-shrink: 0; + text-align: right; + } + } +} +