Add Caps Lock moderatorpull/4/head
| @@ -69,6 +69,33 @@ Below is the complete configuration reference: | |||||
| "message": "Hello World! HeyGuys" | "message": "Hello World! HeyGuys" | ||||
| } | } | ||||
| ] | ] | ||||
| }, | |||||
| "moderator": { | |||||
| // The configuration of the moderator (see bellow for more information) | |||||
| } | } | ||||
| } | } | ||||
| ``` | ``` | ||||
| ### The Moderator | |||||
| Twason has features to help you moderate your chat automatically against most of the nuisance that streamers may face to. | |||||
| Any moderation feature has to be activated in the configuration in the `moderator` section of the `config.json` file, and have the same options: | |||||
| ```json5 | |||||
| { | |||||
| "moderation-feature": { // replace the name with the moderation feature name | |||||
| "activate": false, // set this to true to activate the feature | |||||
| "decision": "delete", | |||||
| "message": "Calm down, {{author}}" // this message will be sent in the chat when a member becomes a pain in the ass | |||||
| } | |||||
| } | |||||
| ``` | |||||
| Some moderation features may include more options. In this case, they have to be included in the same way. | |||||
| The available moderation features are the following: | |||||
| - `caps-lock`: moderate the messages written in CAPS LOCK | |||||
| Additional options: | |||||
| - `min_size`: the minimum size of the message to moderate | |||||
| - `threshold`: the percentage of capital letters that will trigger the moderation | |||||
| @@ -21,6 +21,8 @@ from os import environ | |||||
| from enum import Enum | from enum import Enum | ||||
| from typing import Union | from typing import Union | ||||
| from .moderator import Moderator, CapsLockModerator, ModerationDecision | |||||
| class Command: | class Command: | ||||
| name: str | name: str | ||||
| @@ -91,15 +93,17 @@ class Config: | |||||
| command_prefix: str | command_prefix: str | ||||
| commands: [Command] | commands: [Command] | ||||
| timer: Timer | timer: Timer | ||||
| moderators: [Moderator] | |||||
| def __init__( | def __init__( | ||||
| self, | |||||
| channel: str, | |||||
| nickname: str, | |||||
| token: str, | |||||
| command_prefix: str, | |||||
| commands: [Command], | |||||
| timer: Timer | |||||
| self, | |||||
| channel: str, | |||||
| nickname: str, | |||||
| token: str, | |||||
| command_prefix: str, | |||||
| commands: [Command], | |||||
| timer: Timer, | |||||
| moderators: [Moderator] | |||||
| ): | ): | ||||
| self.nickname = nickname | self.nickname = nickname | ||||
| self.channel = channel | self.channel = channel | ||||
| @@ -107,6 +111,7 @@ class Config: | |||||
| self.command_prefix = command_prefix | self.command_prefix = command_prefix | ||||
| self.commands = commands | self.commands = commands | ||||
| self.timer = timer | self.timer = timer | ||||
| self.moderators = moderators | |||||
| @classmethod | @classmethod | ||||
| def from_dict(cls, params: dict, token: str): | def from_dict(cls, params: dict, token: str): | ||||
| @@ -131,6 +136,26 @@ class Config: | |||||
| commands.append(command) | commands.append(command) | ||||
| moderators = [] | |||||
| for moderator in params.get('moderator', []): | |||||
| decision_str = params['moderator']['caps-lock'].get("decision", "delete") | |||||
| if decision_str == "delete": | |||||
| decision = ModerationDecision.DELETE_MSG | |||||
| elif decision_str == "timeout": | |||||
| decision = ModerationDecision.TIMEOUT_USER | |||||
| else: | |||||
| print("WARNING: %s moderator's decision is invalid, it has been deactivated!") | |||||
| decision = ModerationDecision.ABSTAIN | |||||
| if moderator == 'caps-lock': | |||||
| moderators.append(CapsLockModerator( | |||||
| params['moderator']['caps-lock'].get("message", "{author}, stop the caps lock!"), | |||||
| params['moderator']['caps-lock'].get("min-size", 5), | |||||
| params['moderator']['caps-lock'].get("threshold", 50), | |||||
| decision, | |||||
| params['moderator']['caps-lock'].get("duration", None) | |||||
| )) | |||||
| # Generate help command | # Generate help command | ||||
| if params.get('help', True): | if params.get('help', True): | ||||
| for command in commands: | for command in commands: | ||||
| @@ -144,7 +169,8 @@ class Config: | |||||
| token, | token, | ||||
| commands_prefix, | commands_prefix, | ||||
| commands, | commands, | ||||
| timer | |||||
| timer, | |||||
| moderators | |||||
| ) | ) | ||||
| def find_command(self, command: str) -> Union[None, Command]: | def find_command(self, command: str) -> Union[None, Command]: | ||||
| @@ -0,0 +1,73 @@ | |||||
| # Twason - The KISS Twitch bot | |||||
| # Copyright (C) 2021 Jérôme Deuchnord | |||||
| # | |||||
| # This program is free software: you can redistribute it and/or modify | |||||
| # it under the terms of the GNU Affero General Public License as | |||||
| # published by the Free Software Foundation, either version 3 of the | |||||
| # License, or (at your option) any later version. | |||||
| # | |||||
| # This program is distributed in the hope that it will be useful, | |||||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||||
| # GNU Affero General Public License for more details. | |||||
| # | |||||
| # You should have received a copy of the GNU Affero General Public License | |||||
| # along with this program. If not, see <https://www.gnu.org/licenses/>. | |||||
| from abc import ABC, abstractmethod | |||||
| from enum import Enum | |||||
| from typing import Union | |||||
| class ModerationDecision(Enum): | |||||
| ABSTAIN = -1 | |||||
| DELETE_MSG = 0 | |||||
| TIMEOUT_USER = 1 | |||||
| class Moderator(ABC): | |||||
| message: str | |||||
| decision: ModerationDecision | |||||
| def __init__(self, message: str, timeout_duration: Union[None, int]): | |||||
| self.message = message | |||||
| self.timeout_duration = timeout_duration | |||||
| @abstractmethod | |||||
| def get_name(self) -> str: | |||||
| pass | |||||
| @abstractmethod | |||||
| def vote(self, msg) -> ModerationDecision: | |||||
| pass | |||||
| class CapsLockModerator(Moderator): | |||||
| def __init__(self, message: str, min_size: int, threshold: int, decision: ModerationDecision, timeout_duration: Union[None, int]): | |||||
| super().__init__(message, timeout_duration) | |||||
| self.min_size = min_size | |||||
| self.threshold = threshold / 100 | |||||
| self.decision = decision | |||||
| def get_name(self) -> str: | |||||
| return 'Caps Lock' | |||||
| def vote(self, msg: str) -> ModerationDecision: | |||||
| msg = ''.join(filter(str.isalpha, msg)) | |||||
| if len(msg) < self.min_size: | |||||
| return ModerationDecision.ABSTAIN | |||||
| n = 0 | |||||
| for char in msg: | |||||
| if char.strip() == '': | |||||
| continue | |||||
| if char == char.upper(): | |||||
| n += 1 | |||||
| if n / len(msg) >= self.threshold: | |||||
| return self.decision | |||||
| return ModerationDecision.ABSTAIN | |||||
| @@ -17,7 +17,11 @@ | |||||
| import irc3 | import irc3 | ||||
| from . import utils | |||||
| from .config import TimerStrategy | from .config import TimerStrategy | ||||
| from .moderator import ModerationDecision, Moderator | |||||
| from random import shuffle | from random import shuffle | ||||
| from datetime import datetime, timedelta | from datetime import datetime, timedelta | ||||
| @@ -26,7 +30,7 @@ config = None | |||||
| @irc3.plugin | @irc3.plugin | ||||
| class TwitchBot: | class TwitchBot: | ||||
| def __init__(self, bot): | |||||
| def __init__(self, bot: irc3.IrcBot): | |||||
| self.config = config | self.config = config | ||||
| self.messages_stack = [] | self.messages_stack = [] | ||||
| self.bot = bot | self.bot = bot | ||||
| @@ -44,24 +48,24 @@ class TwitchBot: | |||||
| print('connection lost') | print('connection lost') | ||||
| @staticmethod | @staticmethod | ||||
| def _parse_variables(in_str: str, mask: str = None): | |||||
| variables = { | |||||
| 'author': mask.split('!')[0] | |||||
| } | |||||
| for key in variables: | |||||
| value = variables[key] | |||||
| def _parse_variables(in_str: str, **kwargs): | |||||
| for key in kwargs: | |||||
| value = kwargs[key] | |||||
| in_str = in_str.replace('{%s}' % key, value) | in_str = in_str.replace('{%s}' % key, value) | ||||
| return in_str | return in_str | ||||
| @irc3.event(irc3.rfc.PRIVMSG) | @irc3.event(irc3.rfc.PRIVMSG) | ||||
| def on_msg(self, target, mask, data, **_): | |||||
| def on_msg(self, mask: str = None, target: str = None, data: str = None, tags: str = None, **_): | |||||
| author = mask.split('!')[0] | |||||
| command = self.config.find_command(data.lower().split(' ')[0]) | command = self.config.find_command(data.lower().split(' ')[0]) | ||||
| tags_dict = utils.parse_tags(tags) | |||||
| if command is not None: | if command is not None: | ||||
| print('%s: %s%s' % (mask, self.config.command_prefix, command.name)) | |||||
| self.bot.privmsg(target, self._parse_variables(command.message, mask)) | |||||
| print('%s: %s%s' % (author, self.config.command_prefix, command.name)) | |||||
| self.bot.privmsg(target, self._parse_variables(command.message, author=author)) | |||||
| elif tags_dict.get('mod') == '0': | |||||
| self.moderate(tags_dict, data, author, target) | |||||
| self.nb_messages_since_timer += 1 | self.nb_messages_since_timer += 1 | ||||
| self.play_timer() | self.play_timer() | ||||
| @@ -86,6 +90,67 @@ class TwitchBot: | |||||
| self.nb_messages_since_timer = 0 | self.nb_messages_since_timer = 0 | ||||
| self.last_timer_date = datetime.now() | self.last_timer_date = datetime.now() | ||||
| def moderate(self, tags: {str: str}, msg: str, author: str, channel: str): | |||||
| print(tags) | |||||
| def delete_msg(mod: Moderator): | |||||
| print("[DELETE (reason: %s)] %s: %s" % (mod.get_name(), author, msg)) | |||||
| self.bot.privmsg( | |||||
| channel, | |||||
| "/delete %s" % tags['id'] | |||||
| ) | |||||
| def timeout(mod: Moderator): | |||||
| print("[TIMEOUT (reason: %s)] %s: %s" % (mod.get_name(), author, msg)) | |||||
| self.bot.privmsg( | |||||
| channel, | |||||
| "/timeout %s %d %s" % ( | |||||
| author, | |||||
| mod.timeout_duration, | |||||
| self._parse_variables(mod.message, author=author) | |||||
| ) | |||||
| ) | |||||
| # Ignore emotes-only messages | |||||
| if tags.get('emote-only', '0') == '1': | |||||
| return | |||||
| message_to_moderate = msg | |||||
| # Remove emotes from message before moderating | |||||
| for emote in tags.get('emotes', '').split('/'): | |||||
| if emote == '': | |||||
| break | |||||
| for indices in emote.split(':')[1].split(','): | |||||
| [first, last] = indices.split('-') | |||||
| first, last = int(first), int(last) | |||||
| if first == 0: | |||||
| message_to_moderate = message_to_moderate[last + 1:] | |||||
| else: | |||||
| message_to_moderate = message_to_moderate[:first - 1] + message_to_moderate[last + 1:] | |||||
| for moderator in self.config.moderators: | |||||
| vote = moderator.vote(message_to_moderate) | |||||
| if vote == ModerationDecision.ABSTAIN: | |||||
| continue | |||||
| if vote == ModerationDecision.DELETE_MSG: | |||||
| delete_msg(moderator) | |||||
| if vote == ModerationDecision.TIMEOUT_USER: | |||||
| timeout(moderator) | |||||
| self.bot.privmsg(channel, self._parse_variables(moderator.message, author=author)) | |||||
| break | |||||
| @irc3.event(irc3.rfc.JOIN) | @irc3.event(irc3.rfc.JOIN) | ||||
| def on_join(self, mask, channel, **_): | def on_join(self, mask, channel, **_): | ||||
| print('JOINED %s as %s' % (channel, mask)) | print('JOINED %s as %s' % (channel, mask)) | ||||
| @irc3.event(irc3.rfc.CONNECTED) | |||||
| def on_connected(self, **_): | |||||
| for line in [ | |||||
| "CAP REQ :twitch.tv/commands", | |||||
| "CAP REQ :twitch.tv/tags" | |||||
| ]: | |||||
| self.bot.send_line(line) | |||||
| self.bot.join('#%s' % self.config.channel) | |||||
| @@ -0,0 +1,9 @@ | |||||
| #!/usr/bin/env python3 | |||||
| def parse_tags(tags_str: str) -> {str: str}: | |||||
| tags_list = [] | |||||
| for tag in tags_str.split(";"): | |||||
| tags_list.append((tag.split("="))) | |||||
| return dict(tags_list) | |||||
| @@ -35,7 +35,7 @@ def main() -> int: | |||||
| bot = irc3.IrcBot.from_config({ | bot = irc3.IrcBot.from_config({ | ||||
| 'nick': twitchbot.config.nickname, | 'nick': twitchbot.config.nickname, | ||||
| 'password': twitchbot.config.token, | 'password': twitchbot.config.token, | ||||
| 'autojoins': [twitchbot.config.channel], | |||||
| 'autojoins': [], | |||||
| 'host': TWITCH_IRC_SERVER, | 'host': TWITCH_IRC_SERVER, | ||||
| 'port': TWITCH_IRC_PORT, | 'port': TWITCH_IRC_PORT, | ||||
| 'ssl': True, | 'ssl': True, | ||||
| @@ -1,27 +1,36 @@ | |||||
| { | { | ||||
| "nickname": "yourbot", | |||||
| "channel": "yourchannel", | |||||
| "command_prefix": "!", | |||||
| "help": true, | |||||
| "commands": [ | |||||
| { | |||||
| "name": "ping", | |||||
| "aliases": ["pong"], | |||||
| "message": "Pong @{author} Kappa" | |||||
| } | |||||
| ], | |||||
| "timer": { | |||||
| "between": { | |||||
| "time": 10, | |||||
| "messages": 10 | |||||
| }, | |||||
| "strategy": "round-robin", | |||||
| "pool": [ | |||||
| { | |||||
| "name": "hello", | |||||
| "aliases": ["hi"], | |||||
| "message": "Hello World! HeyGuys" | |||||
| } | |||||
| ] | |||||
| } | |||||
| "nickname": "yourbot", | |||||
| "channel": "yourchannel", | |||||
| "command_prefix": "!", | |||||
| "help": true, | |||||
| "commands": [ | |||||
| { | |||||
| "name": "ping", | |||||
| "aliases": ["pong"], | |||||
| "message": "Pong @{author} Kappa" | |||||
| } | |||||
| ], | |||||
| "timer": { | |||||
| "between": { | |||||
| "time": 10, | |||||
| "messages": 10 | |||||
| }, | |||||
| "strategy": "round-robin", | |||||
| "pool": [ | |||||
| { | |||||
| "name": "hello", | |||||
| "aliases": ["hi"], | |||||
| "message": "Hello World! HeyGuys" | |||||
| } | |||||
| ] | |||||
| }, | |||||
| "moderator": { | |||||
| "caps-lock": { | |||||
| "activate": true, | |||||
| "min-size": 5, | |||||
| "threshold": 50, | |||||
| "decision": "delete", | |||||
| "message": "DON'T SHOUT LIKE THAT, {author}!!!" | |||||
| } | |||||
| } | |||||
| } | } | ||||