From c1691c0ab88e96a02f5c95bdada1ba7cf95aa039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Deuchnord?= Date: Sun, 22 Aug 2021 18:13:02 +0200 Subject: [PATCH] Add Caps Lock moderator --- README.md | 27 +++++++++++++ _twitchbot/config.py | 42 ++++++++++++++++---- _twitchbot/moderator.py | 73 ++++++++++++++++++++++++++++++++++ _twitchbot/twitchbot.py | 87 +++++++++++++++++++++++++++++++++++------ _twitchbot/utils.py | 9 +++++ bot.py | 2 +- config.json.dist | 59 ++++++++++++++++------------ 7 files changed, 254 insertions(+), 45 deletions(-) create mode 100644 _twitchbot/moderator.py create mode 100644 _twitchbot/utils.py mode change 100644 => 100755 bot.py diff --git a/README.md b/README.md index 2a4894d..78071e4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,33 @@ Below is the complete configuration reference: "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 diff --git a/_twitchbot/config.py b/_twitchbot/config.py index 7134627..97121f6 100644 --- a/_twitchbot/config.py +++ b/_twitchbot/config.py @@ -21,6 +21,8 @@ from os import environ from enum import Enum from typing import Union +from .moderator import Moderator, CapsLockModerator, ModerationDecision + class Command: name: str @@ -91,15 +93,17 @@ class Config: command_prefix: str commands: [Command] timer: Timer + moderators: [Moderator] 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.channel = channel @@ -107,6 +111,7 @@ class Config: self.command_prefix = command_prefix self.commands = commands self.timer = timer + self.moderators = moderators @classmethod def from_dict(cls, params: dict, token: str): @@ -131,6 +136,26 @@ class Config: 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 if params.get('help', True): for command in commands: @@ -144,7 +169,8 @@ class Config: token, commands_prefix, commands, - timer + timer, + moderators ) def find_command(self, command: str) -> Union[None, Command]: diff --git a/_twitchbot/moderator.py b/_twitchbot/moderator.py new file mode 100644 index 0000000..575a28e --- /dev/null +++ b/_twitchbot/moderator.py @@ -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 . + + +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 diff --git a/_twitchbot/twitchbot.py b/_twitchbot/twitchbot.py index f15424c..a21dc2f 100644 --- a/_twitchbot/twitchbot.py +++ b/_twitchbot/twitchbot.py @@ -17,7 +17,11 @@ import irc3 +from . import utils + from .config import TimerStrategy +from .moderator import ModerationDecision, Moderator + from random import shuffle from datetime import datetime, timedelta @@ -26,7 +30,7 @@ config = None @irc3.plugin class TwitchBot: - def __init__(self, bot): + def __init__(self, bot: irc3.IrcBot): self.config = config self.messages_stack = [] self.bot = bot @@ -44,24 +48,24 @@ class TwitchBot: print('connection lost') @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) return in_str @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]) + tags_dict = utils.parse_tags(tags) 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.play_timer() @@ -86,6 +90,67 @@ class TwitchBot: self.nb_messages_since_timer = 0 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) def on_join(self, mask, channel, **_): 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) diff --git a/_twitchbot/utils.py b/_twitchbot/utils.py new file mode 100644 index 0000000..2dff5a4 --- /dev/null +++ b/_twitchbot/utils.py @@ -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) diff --git a/bot.py b/bot.py old mode 100644 new mode 100755 index bf2eed4..0a415fb --- a/bot.py +++ b/bot.py @@ -35,7 +35,7 @@ def main() -> int: bot = irc3.IrcBot.from_config({ 'nick': twitchbot.config.nickname, 'password': twitchbot.config.token, - 'autojoins': [twitchbot.config.channel], + 'autojoins': [], 'host': TWITCH_IRC_SERVER, 'port': TWITCH_IRC_PORT, 'ssl': True, diff --git a/config.json.dist b/config.json.dist index 2e6dd61..88ff005 100644 --- a/config.json.dist +++ b/config.json.dist @@ -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}!!!" + } + } }