Add Caps Lock moderatorpull/4/head
@@ -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 |
@@ -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]: | |||
@@ -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 | |||
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) |
@@ -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({ | |||
'nick': twitchbot.config.nickname, | |||
'password': twitchbot.config.token, | |||
'autojoins': [twitchbot.config.channel], | |||
'autojoins': [], | |||
'host': TWITCH_IRC_SERVER, | |||
'port': TWITCH_IRC_PORT, | |||
'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}!!!" | |||
} | |||
} | |||
} |