4 Commits

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 3 years ago
  Jérôme Deuchnord dc061b869c Add Flood moderator 3 years ago
  Jérôme Deuchnord 6960cd81c1 Add icon 3 years ago
7 changed files with 228 additions and 31 deletions
Unified View
  1. +10
    -0
      README.md
  2. +34
    -20
      _twitchbot/config.py
  3. +83
    -6
      _twitchbot/moderator.py
  4. +20
    -0
      _twitchbot/twitch_message.py
  5. +21
    -5
      _twitchbot/twitchbot.py
  6. BIN
      icon.png
  7. +60
    -0
      icon.svg

+ 10
- 0
README.md View File

@@ -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

+ 34
- 20
_twitchbot/config.py View File

@@ -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


+ 83
- 6
_twitchbot/moderator.py View File

@@ -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()

+ 20
- 0
_twitchbot/twitch_message.py View File

@@ -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+)$'


+ 21
- 5
_twitchbot/twitchbot.py View File

@@ -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:


BIN
icon.png View File

Before After
Width: 325  |  Height: 261  |  Size: 26 KiB

+ 60
- 0
icon.svg
File diff suppressed because it is too large
View File


Loading…
Cancel
Save