Author | SHA1 | Message | Date |
---|---|---|---|
Jérôme Deuchnord | ea56c3ab6a | Update doc | 3 years ago |
Jérôme Deuchnord |
8f40cdc284
|
Merge pull request #4 from Deuchnord/flood-moderator
Add Flood moderator |
3 years ago |
Jérôme Deuchnord | dc061b869c | Add Flood moderator | 3 years ago |
Jérôme Deuchnord | 6960cd81c1 | Add icon | 3 years ago |
@@ -86,6 +86,7 @@ Any moderation feature has to be activated in the configuration in the `moderato | |||||
"moderation-feature": { // replace the name with the moderation feature name | "moderation-feature": { // replace the name with the moderation feature name | ||||
"activate": false, // set this to true to activate the feature | "activate": false, // set this to true to activate the feature | ||||
"decision": "delete", // the action to take: "delete" or "timeout" | "decision": "delete", // the action to take: "delete" or "timeout" | ||||
"duration": 5, // if decision is timeout, the duration of the ban, in seconds | |||||
"message": "Calm down, {author}" // this message will be sent in the chat when a member becomes a pain in the ass | "message": "Calm down, {author}" // this message will be sent in the chat when a member becomes a pain in the ass | ||||
} | } | ||||
} | } | ||||
@@ -99,3 +100,12 @@ The available moderation features are the following: | |||||
Additional options: | Additional options: | ||||
- `min-size`: the minimum size of the message to moderate | - `min-size`: the minimum size of the message to moderate | ||||
- `threshold`: the percentage of capital letters that will trigger the moderation | - `threshold`: the percentage of capital letters that will trigger the moderation | ||||
- `flood`: prevent the members of the chat to flood in your chat | |||||
Additional options: | |||||
- `max-word-length`: the maximum length of a word | |||||
- `ignore-hashtags`: if `true`, don't moderate the hashtags (defaults to `false`) | |||||
- to moderate the unwanted repetition of messages, you will need to add these two options: | |||||
- `max-msg-occurrences`: the number of times a message can be repeated before it gets moderated | |||||
- `min-time-between-occurrence`: the time in which a message is counted, in seconds | |||||
a member will be moderated if they send `max-msg-occurrences` in `min-time-between-occurrence` seconds | |||||
- `raid-cooldown`: when a raid happens, the time in minutes of cooldown in which the flood is authorized |
@@ -21,7 +21,7 @@ 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 | |||||
from . import moderator | |||||
class Command: | class Command: | ||||
@@ -93,7 +93,7 @@ class Config: | |||||
command_prefix: str | command_prefix: str | ||||
commands: [Command] | commands: [Command] | ||||
timer: Timer | timer: Timer | ||||
moderators: [Moderator] | |||||
moderators: [moderator.Moderator] | |||||
def __init__( | def __init__( | ||||
self, | self, | ||||
@@ -103,7 +103,7 @@ class Config: | |||||
command_prefix: str, | command_prefix: str, | ||||
commands: [Command], | commands: [Command], | ||||
timer: Timer, | timer: Timer, | ||||
moderators: [Moderator] | |||||
moderators: [moderator.Moderator] | |||||
): | ): | ||||
self.nickname = nickname | self.nickname = nickname | ||||
self.channel = channel | self.channel = channel | ||||
@@ -137,23 +137,26 @@ class Config: | |||||
commands.append(command) | commands.append(command) | ||||
moderators = [] | 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) | |||||
for mod in params.get('moderator', []): | |||||
moderator_config = params['moderator'][mod] | |||||
if mod == 'caps-lock': | |||||
moderators.append(moderator.CapsLockModerator( | |||||
moderator_config.get("message", "{author}, stop the caps lock!"), | |||||
cls.parse_decision(moderator_config.get("decision", "delete")), | |||||
moderator_config.get("duration", None), | |||||
moderator_config.get("min-size", 5), | |||||
moderator_config.get("threshold", 50) | |||||
)) | |||||
if mod == 'flood': | |||||
moderators.append(moderator.FloodModerator( | |||||
moderator_config.get("message", "{author}, stop the flood!"), | |||||
cls.parse_decision(moderator_config.get("decision", "timeout")), | |||||
moderator_config.get("duration", None), | |||||
moderator_config.get("max-word-length", None), | |||||
moderator_config.get("raid-cooldown", None), | |||||
moderator_config.get("ignore-hashtags", False), | |||||
moderator_config.get("max-msg-occurrences", None), | |||||
moderator_config.get("min-time-between-occurrence", None) | |||||
)) | )) | ||||
# Generate help command | # Generate help command | ||||
@@ -173,6 +176,17 @@ class Config: | |||||
moderators | moderators | ||||
) | ) | ||||
@classmethod | |||||
def parse_decision(cls, decision_str) -> moderator.ModerationDecision: | |||||
if decision_str == "delete": | |||||
decision = moderator.ModerationDecision.DELETE_MSG | |||||
elif decision_str == "timeout": | |||||
decision = moderator.ModerationDecision.TIMEOUT_USER | |||||
else: | |||||
print("WARNING: %s moderator's decision is invalid, it has been deactivated!") | |||||
decision = moderator.ModerationDecision.ABSTAIN | |||||
return decision | |||||
def find_command(self, command: str) -> Union[None, Command]: | def find_command(self, command: str) -> Union[None, Command]: | ||||
if not command.startswith(self.command_prefix): | if not command.startswith(self.command_prefix): | ||||
return None | return None | ||||
@@ -18,6 +18,9 @@ | |||||
from abc import ABC, abstractmethod | from abc import ABC, abstractmethod | ||||
from enum import Enum | from enum import Enum | ||||
from typing import Union | from typing import Union | ||||
from datetime import datetime, timedelta | |||||
EPOCH = datetime(1970, 1, 1) | |||||
class ModerationDecision(Enum): | class ModerationDecision(Enum): | ||||
@@ -30,31 +33,38 @@ class Moderator(ABC): | |||||
message: str | message: str | ||||
decision: ModerationDecision | decision: ModerationDecision | ||||
def __init__(self, message: str, timeout_duration: Union[None, int]): | |||||
def __init__(self, message: str, decision: ModerationDecision, timeout_duration: Union[None, int]): | |||||
self.message = message | self.message = message | ||||
self.timeout_duration = timeout_duration | self.timeout_duration = timeout_duration | ||||
self.decision = decision | |||||
@abstractmethod | @abstractmethod | ||||
def get_name(self) -> str: | def get_name(self) -> str: | ||||
pass | pass | ||||
@abstractmethod | @abstractmethod | ||||
def vote(self, msg) -> ModerationDecision: | |||||
def vote(self, msg: str, author: str) -> ModerationDecision: | |||||
pass | pass | ||||
class CapsLockModerator(Moderator): | 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) | |||||
def __init__( | |||||
self, | |||||
message: str, | |||||
decision: ModerationDecision, | |||||
timeout_duration: Union[None, int], | |||||
min_size: int, | |||||
threshold: int | |||||
): | |||||
super().__init__(message, decision, timeout_duration) | |||||
self.min_size = min_size | self.min_size = min_size | ||||
self.threshold = threshold / 100 | self.threshold = threshold / 100 | ||||
self.decision = decision | |||||
def get_name(self) -> str: | def get_name(self) -> str: | ||||
return 'Caps Lock' | return 'Caps Lock' | ||||
def vote(self, msg: str) -> ModerationDecision: | |||||
def vote(self, msg: str, author: str) -> ModerationDecision: | |||||
msg = ''.join(filter(str.isalpha, msg)) | msg = ''.join(filter(str.isalpha, msg)) | ||||
if len(msg) < self.min_size: | if len(msg) < self.min_size: | ||||
@@ -71,3 +81,70 @@ class CapsLockModerator(Moderator): | |||||
return self.decision | return self.decision | ||||
return ModerationDecision.ABSTAIN | return ModerationDecision.ABSTAIN | ||||
class FloodModerator(Moderator): | |||||
def __init__( | |||||
self, | |||||
message: str, | |||||
decision: ModerationDecision, | |||||
timeout_duration: Union[None, int], | |||||
max_word_length: Union[None, int], | |||||
raid_cooldown: Union[None, int], | |||||
ignore_hashtags: bool, | |||||
max_msg_occurrences: Union[None, int], | |||||
min_time_between_occurrence: Union[None, int] | |||||
): | |||||
super().__init__(message, decision, timeout_duration) | |||||
self.max_word_length = max_word_length | |||||
self.raid_cooldown = raid_cooldown | |||||
self.last_raid = EPOCH | |||||
self.ignore_hashtags = ignore_hashtags | |||||
self.max_msg_occurrences = max_msg_occurrences | |||||
self.min_time_between_occurrence = min_time_between_occurrence | |||||
self.last_msgs = [] | |||||
def get_name(self) -> str: | |||||
return 'Flood' | |||||
def vote(self, msg: str, author: str) -> ModerationDecision: | |||||
if self.raid_cooldown is not None and self.last_raid + timedelta(minutes=self.raid_cooldown) > datetime.now(): | |||||
return ModerationDecision.ABSTAIN | |||||
if self.max_word_length is not None: | |||||
for word in msg.split(' '): | |||||
if word.startswith('#'): | |||||
continue | |||||
if len(word) > self.max_word_length: | |||||
return ModerationDecision.TIMEOUT_USER | |||||
if self.max_msg_occurrences is None or self.min_time_between_occurrence is None: | |||||
return ModerationDecision.ABSTAIN | |||||
clean_msg = None | |||||
for last_msg in self.last_msgs: | |||||
if last_msg['first-occurrence'] + timedelta(seconds=self.min_time_between_occurrence) <= datetime.now(): | |||||
clean_msg = last_msg | |||||
break | |||||
if author != last_msg['author'] or msg != last_msg['message']: | |||||
break | |||||
last_msg['occurrences'] += 1 | |||||
if last_msg['occurrences'] >= self.max_msg_occurrences: | |||||
return ModerationDecision.TIMEOUT_USER | |||||
if clean_msg is not None: | |||||
self.last_msgs.remove(clean_msg) | |||||
self.last_msgs.append({ | |||||
'first-occurrence': datetime.now(), | |||||
'author': author, | |||||
'message': msg, | |||||
'occurrences': 1 | |||||
}) | |||||
return ModerationDecision.ABSTAIN | |||||
def declare_raid(self): | |||||
self.last_raid = datetime.now() |
@@ -0,0 +1,20 @@ | |||||
# 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 irc3.rfc import raw | |||||
USERNOTICE = r'^(@(?P<tags>\S+) )?:(?P<mask>\S+) (?P<event>(USERNOTICE)) (?P<target>\S+)$' | |||||
@@ -17,13 +17,15 @@ | |||||
import irc3 | import irc3 | ||||
from random import shuffle | |||||
from datetime import datetime, timedelta | |||||
from . import utils | from . import utils | ||||
from .config import TimerStrategy | from .config import TimerStrategy | ||||
from .moderator import ModerationDecision, Moderator | |||||
from .moderator import ModerationDecision, Moderator, FloodModerator | |||||
from . import twitch_message | |||||
from random import shuffle | |||||
from datetime import datetime, timedelta | |||||
config = None | config = None | ||||
@@ -55,6 +57,10 @@ class TwitchBot: | |||||
return in_str | return in_str | ||||
# @irc3.event(r'^(?P<data>.+)$') | |||||
# def on_all(self, data): | |||||
# print(data) | |||||
@irc3.event(irc3.rfc.PRIVMSG) | @irc3.event(irc3.rfc.PRIVMSG) | ||||
def on_msg(self, mask: str = None, target: str = None, data: str = None, tags: str = None, **_): | def on_msg(self, mask: str = None, target: str = None, data: str = None, tags: str = None, **_): | ||||
author = mask.split('!')[0] | author = mask.split('!')[0] | ||||
@@ -70,6 +76,17 @@ class TwitchBot: | |||||
self.nb_messages_since_timer += 1 | self.nb_messages_since_timer += 1 | ||||
self.play_timer() | self.play_timer() | ||||
@irc3.event(twitch_message.USERNOTICE) | |||||
def on_user_notice(self, tags: str, **_): | |||||
tags = utils.parse_tags(tags) | |||||
if tags.get('msg-id', None) == 'raid': | |||||
# Notice the Flood moderator a raid has just happened | |||||
for moderator in self.config.moderators: | |||||
if isinstance(moderator, FloodModerator) and moderator.raid_cooldown is not None: | |||||
print("Raid received from %s. Disabling the Flood moderator." % tags.get('display-name')) | |||||
moderator.declare_raid() | |||||
break | |||||
def play_timer(self): | def play_timer(self): | ||||
if not self.messages_stack: | if not self.messages_stack: | ||||
print('Filling the timer messages stack in') | print('Filling the timer messages stack in') | ||||
@@ -91,7 +108,6 @@ class TwitchBot: | |||||
self.last_timer_date = datetime.now() | self.last_timer_date = datetime.now() | ||||
def moderate(self, tags: {str: str}, msg: str, author: str, channel: str): | def moderate(self, tags: {str: str}, msg: str, author: str, channel: str): | ||||
print(tags) | |||||
def delete_msg(mod: Moderator): | def delete_msg(mod: Moderator): | ||||
print("[DELETE (reason: %s)] %s: %s" % (mod.get_name(), author, msg)) | print("[DELETE (reason: %s)] %s: %s" % (mod.get_name(), author, msg)) | ||||
self.bot.privmsg( | self.bot.privmsg( | ||||
@@ -130,7 +146,7 @@ class TwitchBot: | |||||
message_to_moderate = message_to_moderate[:first - 1] + message_to_moderate[last + 1:] | message_to_moderate = message_to_moderate[:first - 1] + message_to_moderate[last + 1:] | ||||
for moderator in self.config.moderators: | for moderator in self.config.moderators: | ||||
vote = moderator.vote(message_to_moderate) | |||||
vote = moderator.vote(message_to_moderate, author) | |||||
if vote == ModerationDecision.ABSTAIN: | if vote == ModerationDecision.ABSTAIN: | ||||
continue | continue | ||||
if vote == ModerationDecision.DELETE_MSG: | if vote == ModerationDecision.DELETE_MSG: | ||||