From a9bd2221dea412f5a5e5d4e7291e3fc68bdc24e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9?= Date: Wed, 19 Aug 2020 14:40:05 +0300 Subject: [PATCH] Moderation (#45) * fixes * fixes * add ro command * add documentation for new commands * add ban (+docs) add messages about errors * refactor exception, move logic send answer to handler * rename commnet with chat name * bugfix deprecated function * now minus triggers must be in first line * remove logging * add save in karma events table all events with karma * add warns, add events restriction * add get info user * update formating info --- README.md | 2 + app/config.py | 4 +- app/filters/karma_change.py | 8 ++- app/handlers/change_karma.py | 8 +-- app/handlers/moderator.py | 115 ++++++++++++++++++++++++++----- app/middlewares/acl.py | 8 +-- app/models/__init__.py | 8 ++- app/models/karma_actions.py | 47 +++++++++++++ app/models/moderator_actions.py | 73 ++++++++++++++++++++ app/models/user_karma.py | 15 ++-- app/services/change_karma.py | 19 +++++ app/services/user_info.py | 9 +++ app/utils/exceptions.py | 12 ++++ app/utils/timedelta_functions.py | 33 +++------ 14 files changed, 302 insertions(+), 59 deletions(-) create mode 100644 app/models/karma_actions.py create mode 100644 app/models/moderator_actions.py create mode 100644 app/services/change_karma.py create mode 100644 app/services/user_info.py diff --git a/README.md b/README.md index 210fd546..9cc06e95 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ moderator commands list: * EEE - count of minutes * FFF - count of seconds (less that 30 seconds will be mean 30 seconds) * you have to specify one or more duration part without spaces +* !warn, !w - official warn user from moderator +* !info - information about user (karma changes, restrictions, warns) superuser commands list: * /generate_invite_logchat - if bot is admin in chat of LOG_CHAT_ID from config.py bot generates invite link to that diff --git a/app/config.py b/app/config.py index 92722dcd..3a5c8b50 100644 --- a/app/config.py +++ b/app/config.py @@ -49,7 +49,9 @@ SUPERUSERS = {GLOBAL_ADMIN_ID, BOMZHEG_ID, BORNTOHACK_ID, ENTERESSI_ID, VADIM_ID, RUD_ID, LET45FC_ID, STUDENT_ID} LOG_CHAT_ID = -1001404337089 -DUMP_CHAT_ID = -1001459777201 # Fucin' Testing Area +DUMP_CHAT_ID = -1001459777201 # ⚙️Testing Area >>> Python Scripts + +DATE_FORMAT = '%d.%m.%Y' WEBHOOK_HOST = os.getenv("WEBHOOK_HOST") WEBHOOK_PORT = os.getenv("WEBHOOK_PORT", default=443) diff --git a/app/filters/karma_change.py b/app/filters/karma_change.py index 6edb3dbf..7bcf13e5 100644 --- a/app/filters/karma_change.py +++ b/app/filters/karma_change.py @@ -42,7 +42,7 @@ def get_karma_trigger(text: str) -> typing.Optional[int]: """ if has_plus_karma(get_first_word(text)): return +1 - if has_minus_karma(text): + if has_minus_karma(get_first_line(text)): return -1 return None @@ -63,6 +63,10 @@ def has_minus_karma(text: str) -> bool: return text in MINUS or (text.split(maxsplit=1)[0] == text and text[0] in MINUS_EMOJI) +def get_first_line(text: str) -> str: + return text.splitlines()[0] + + def get_target_user(message: types.Message) -> typing.Optional[types.user.User]: """ Target user can be take from reply or by mention @@ -123,4 +127,4 @@ def is_bot_username(username: str): """ this function deprecated. user can use username like @alice_bot and it don't say that it is bot """ - return username is not None and username[-2:] == "bot" + return username is not None and username[-3:] == "bot" diff --git a/app/handlers/change_karma.py b/app/handlers/change_karma.py index 2c7b207b..992bd0b0 100644 --- a/app/handlers/change_karma.py +++ b/app/handlers/change_karma.py @@ -6,8 +6,8 @@ from app.misc import dp from app.models.chat import Chat from app.models.user import User -from app.models.user_karma import UserKarma from app.services.user_getter import UserGetter +from app.services.change_karma import change_karma from app.utils.exceptions import UserWithoutUserIdError, SubZeroKarma how_change = { @@ -46,10 +46,10 @@ async def karma_change(message: types.Message, karma: dict, user: User, chat: Ch return logger.info("user {user} try to change self or bot karma ", user=user.tg_id) try: - uk, power = await UserKarma.change_or_create( + uk, power = await change_karma( target_user=target_user, chat=chat, - user_changed=user, + user=user, how_change=karma['karma_change'] ) except SubZeroKarma: @@ -59,7 +59,7 @@ async def karma_change(message: types.Message, karma: dict, user: User, chat: Ch return_text = ( "Вы {how_change} карму " "{name} до {karma_new} " - "({power:+.1f})".format( + "({power:+.2f})".format( how_change=how_change[karma['karma_change']], name=hbold(target_user.fullname), karma_new=hbold(uk.karma_round), diff --git a/app/handlers/moderator.py b/app/handlers/moderator.py index 171aafcb..74126bdd 100644 --- a/app/handlers/moderator.py +++ b/app/handlers/moderator.py @@ -1,14 +1,19 @@ +import typing from datetime import timedelta from aiogram import types -from aiogram.dispatcher.filters.builtin import Text, Command -from aiogram.utils.exceptions import BadRequest -from aiogram.utils.markdown import hide_link +from aiogram.dispatcher.filters.builtin import Command +from aiogram.utils.exceptions import BadRequest, Unauthorized +from aiogram.utils.markdown import hide_link, quote_html from loguru import logger -from app.misc import dp -from app.utils.timedelta_functions import parse_timedelta_from_message, format_timedelta +from app.misc import dp, bot +from app.utils.timedelta_functions import parse_timedelta_from_text, format_timedelta +from app.utils.exceptions import TimedeltaParseError +from app.models import ModeratorEvent, Chat, User +from app.services.user_info import get_user_info FOREVER_DURATION = timedelta(days=366) +DEFAULT_DURATION = timedelta(hours=1) async def report_filter(message: types.Message): @@ -18,9 +23,6 @@ async def report_filter(message: types.Message): return False if await Command(commands=['report', 'admin'], prefixes='/!@').check(message): return True - if await Text(equals='@admin', ignore_case=True).check(message): - print("text with @admin 0_o") - return True return False @@ -49,6 +51,16 @@ def need_notify_admin(admin: types.ChatMember): return admin.can_delete_messages or admin.can_restrict_members or admin.status == types.ChatMemberStatus.CREATOR +def get_moderator_message_args(text: str) -> typing.Tuple[str, str]: + _, *args = text.split(maxsplit=2) # in text: command_duration_comments + if not args: + return "", "" + duration_text = args[0] + if len(args) == 1: + return duration_text, "" + return duration_text, " ".join(args[1:]) + + @dp.message_handler( commands=["ro", "mute"], commands_prefix="!", @@ -56,10 +68,15 @@ def need_notify_admin(admin: types.ChatMember): user_can_restrict_members=True, bot_can_restrict_members=True, ) -async def cmd_ro(message: types.Message): - duration = await parse_timedelta_from_message(message) - if not duration: - return +async def cmd_ro(message: types.Message, chat: Chat): + duration_text, comment = get_moderator_message_args(message.text) + if duration_text: + try: + duration = parse_timedelta_from_text(duration_text) + except TimedeltaParseError as e: + return await message.reply(f"Не могу распознать время. {quote_html(e.text)}") + else: + duration = DEFAULT_DURATION try: await message.chat.restrict( @@ -74,6 +91,15 @@ async def cmd_ro(message: types.Message): except BadRequest as e: logger.error("Failed to restrict chat member: {error!r}", error=e) return False + else: + await ModeratorEvent.save_new_action( + moderator=message.from_user, + user=message.reply_to_message.from_user, + chat=chat, + type_restriction="ro", + duration=duration, + comment=comment + ) await message.reply( "Пользователь {user} сможет только читать сообщения на протяжении {duration}".format( @@ -90,10 +116,15 @@ async def cmd_ro(message: types.Message): user_can_restrict_members=True, bot_can_restrict_members=True, ) -async def cmd_ban(message: types.Message): - duration = await parse_timedelta_from_message(message) - if not duration: - return +async def cmd_ban(message: types.Message, chat: Chat): + duration_text, comment = get_moderator_message_args(message.text) + if duration_text: + try: + duration = parse_timedelta_from_text(duration_text) + except TimedeltaParseError as e: + return await message.reply(f"Не могу распознать время. {quote_html(e.text)}") + else: + duration = DEFAULT_DURATION try: await message.chat.kick(message.reply_to_message.from_user.id, until_date=duration) @@ -106,6 +137,15 @@ async def cmd_ban(message: types.Message): except BadRequest as e: logger.error("Failed to kick chat member: {error!r}", error=e) return False + else: + await ModeratorEvent.save_new_action( + moderator=message.from_user, + user=message.reply_to_message.from_user, + chat=chat, + type_restriction="ban", + duration=duration, + comment=comment + ) text = "Пользователь {user} попал в бан этого чата.".format( user=message.reply_to_message.from_user.get_mention(), @@ -132,3 +172,46 @@ async def cmd_ro_bot_not_admin(message: types.Message): ) async def cmd_ro_bot_not_admin(message: types.Message): await message.delete() + + +@dp.message_handler( + commands=["w", "warn"], + commands_prefix="!", + is_reply=True, + user_can_restrict_members=True, +) +async def cmd_warn(message: types.Message, chat: Chat): + args = message.text.split(maxsplit=1) + if len(args) == 1: + comment = "" + else: + comment = args[1] + + await ModeratorEvent.save_new_action( + moderator=message.from_user, + user=message.reply_to_message.from_user, + chat=chat, + type_restriction="warn", + comment=comment + ) + + text = "Пользователь {user} получил официальное предупреждение от модератора".format( + user=message.reply_to_message.from_user.get_mention(), + ) + await message.reply(text) + + +@dp.message_handler(commands="info", commands_prefix='!', is_reply=True) +async def get_info_about_user(message: types.Message, chat: Chat): + target_user = await User.get_or_create_from_tg_user(message.reply_to_message.from_user) + info = await get_user_info(target_user, chat) + try: + await bot.send_message( + message.from_user.id, + f"Данные на {target_user.mention_link}:\n" + "\n".join(info), + disable_web_page_preview=True + ) + except Unauthorized: + await message.reply("Напишите мне в личку /start и повторите команду.") + finally: + await message.delete() diff --git a/app/middlewares/acl.py b/app/middlewares/acl.py index 08a2bd51..955a36be 100644 --- a/app/middlewares/acl.py +++ b/app/middlewares/acl.py @@ -17,18 +17,16 @@ def __init__(self): self.lock_factory = LockFactory() async def setup_chat(self, data: dict, user: types.User, chat: Optional[types.Chat] = None): - logger.debug("starting setup chat {chat}", chat=chat.id) try: - async with self.lock_factory.get_lock(f"{chat.id}:{user.id}"): - logger.debug("in lock setup chat {chat}", chat=chat.id) + async with self.lock_factory.get_lock(f"{user.id}"): user = await User.get_or_create_from_tg_user(user) - if chat and chat.type != 'private': + if chat and chat.type != 'private': + async with self.lock_factory.get_lock(f"{chat.id}"): chat = await Chat.get_or_create_from_tg_chat(chat) except Exception as e: logger.error("troubles with db") raise e - logger.debug("after lock setup chat {chat}", chat=chat) data["user"] = user data["chat"] = chat diff --git a/app/models/__init__.py b/app/models/__init__.py index 18a780e5..8c59a22e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,10 +1,14 @@ from .chat import Chat, ChatType from .user import User from .user_karma import UserKarma +from .karma_actions import KarmaEvent +from .moderator_actions import ModeratorEvent -__all__ = [Chat, ChatType, User, UserKarma] +__all__ = [Chat, ChatType, User, UserKarma, KarmaEvent] __models__ = [ 'app.models.user', 'app.models.chat', - 'app.models.user_karma' + 'app.models.user_karma', + 'app.models.karma_actions', + 'app.models.moderator_actions', ] diff --git a/app/models/karma_actions.py b/app/models/karma_actions.py new file mode 100644 index 00000000..157ee945 --- /dev/null +++ b/app/models/karma_actions.py @@ -0,0 +1,47 @@ +from tortoise import fields +from tortoise.models import Model + +from .user import User +from .chat import Chat +from app import config + + +class KarmaEvent(Model): + id_ = fields.IntField(pk=True, source_field="id") + user_from: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='i_change_karma_events') + user_to: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='my_karma_events') + chat: fields.ForeignKeyRelation[Chat] = fields.ForeignKeyField( + 'models.Chat', related_name='karma_events') + date = fields.DatetimeField(auto_now=True, null=False) + how_change = fields.FloatField( + description="how match change karma in percent of possible power" + ) + how_match_change = fields.FloatField( + description="how match user_from change karma user_to in absolute" + ) + comment = fields.TextField(null=True) + + class Meta: + table = 'karma_events' + + def __str__(self): + return ( + f"KarmaEvent {self.id_} from user {self.user_from.id} to {self.user_to.id}, " + f"date {self.date}, change {self.how_change}" + ) + __repr__ = __str__ + + @classmethod + async def get_last_by_user(cls, user: User, chat: Chat, limit: int = 10): + return await cls.filter( + user_to=user, + chat=chat + ).order_by('-date').limit(limit).prefetch_related("user_from").all() + + def format_event(self): + return ( + f"{self.date.date().strftime(config.DATE_FORMAT)} " + f"{self.user_from.mention_no_link} изменил карму на {self.how_change:.0%} своей силы. " + ) + (f"\"{self.comment}\"" if self.comment is not None else "") diff --git a/app/models/moderator_actions.py b/app/models/moderator_actions.py new file mode 100644 index 00000000..43085d8e --- /dev/null +++ b/app/models/moderator_actions.py @@ -0,0 +1,73 @@ +from datetime import timedelta + +from aiogram import types +from tortoise import fields +from tortoise.models import Model + +from .user import User +from .chat import Chat + +from app import config +from app.utils.timedelta_functions import format_timedelta + + +class ModeratorEvent(Model): + id_ = fields.IntField(pk=True, source_field="id") + moderator: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='my_moderator_events') + user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='my_restriction_events') + chat: fields.ForeignKeyRelation[Chat] = fields.ForeignKeyField( + 'models.Chat', related_name='moderator_events') + date = fields.DatetimeField(auto_now=True, null=False) + type_restriction = fields.CharField(max_length=20) + timedelta_restriction = fields.TimeDeltaField(null=True) + comment = fields.TextField(null=True) + + class Meta: + table = 'moderator_events' + + def __str__(self): + return ( + f"KarmaEvent {self.id_} from moderator {self.moderator.id} to {self.user.id}, date {self.date}, " + f"type_restriction {self.type_restriction} timedelta_restriction {self.timedelta_restriction}" + ) + __repr__ = __str__ + + @classmethod + async def save_new_action( + cls, + moderator: types.User, + user: types.User, + chat: Chat, + type_restriction: str, + duration: timedelta = None, + comment: str = "" + ): + await ModeratorEvent( + moderator=await User.get_or_create_from_tg_user(moderator), + user=await User.get_or_create_from_tg_user(user), + chat=chat, + type_restriction=type_restriction, + timedelta_restriction=duration, + comment=comment + ).save() + + @classmethod + async def get_last_by_user(cls, user: User, chat: Chat, limit: int = 10): + return await cls.filter( + user=user, + chat=chat + ).order_by('-date').limit(limit).prefetch_related('moderator').all() + + def format_event(self) -> str: + rez = f"{self.date.date().strftime(config.DATE_FORMAT)} {self.type_restriction} " + + if self.timedelta_restriction: + rez += f"{format_timedelta(self.timedelta_restriction)} " + + rez += f"от {self.moderator.mention_no_link}" + + if self.comment: + rez += f" \"{self.comment}\"" + return rez diff --git a/app/models/user_karma.py b/app/models/user_karma.py index f558aae2..9ea0cc2f 100644 --- a/app/models/user_karma.py +++ b/app/models/user_karma.py @@ -9,6 +9,9 @@ class UserKarma(Model): + """ + information about (karma) (user) in (chat) + """ uc_id = fields.IntField(pk=True) user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField('models.User', related_name='karma') chat: fields.ForeignKeyRelation[Chat] = fields.ForeignKeyField('models.Chat', related_name='user_karma') @@ -26,10 +29,10 @@ def __str__(self): def __repr__(self): return str(self) - async def change(self, user_changed: User, how_change: int): + async def change(self, user_changed: User, how_change: float): """ change karma to (self.user) from (user_changed) - (how_change) must be +1 or -1 + (how_change) must be from -1 to +1 (power from -100% to +100 %) """ if abs(how_change) != 1: raise ValueError(f"how_change must be +1 or -1 but it is {how_change}") @@ -46,10 +49,10 @@ async def change(self, user_changed: User, how_change: int): return power @classmethod - async def change_or_create(cls, target_user: User, chat: Chat, user_changed: User, how_change: int): + async def change_or_create(cls, target_user: User, chat: Chat, user_changed: User, how_change: float): """ change karma to (target_user) from (user_changed) in (chat) - (how_change) must be +1 or -1 + (how_change) must be from -1 to +1 (power from -100% to +100 %) """ uk, _ = await UserKarma.get_or_create( user=target_user, @@ -76,10 +79,10 @@ def karma_round(self) -> float: @classmethod async def all_to_json(cls, chat_id: int = None) -> dict: if chat_id is not None: - karms = await cls.filter(chat_id=chat_id).prefetch_related("user").order_by("-karma") + uks = await cls.filter(chat_id=chat_id).prefetch_related("user").order_by("-karma") return { chat_id: [ - {**karm.user.to_json(), "karma": karm.karma} for karm in karms + {**uk.user.to_json(), "karma": uk.karma} for uk in uks ] } else: diff --git a/app/services/change_karma.py b/app/services/change_karma.py new file mode 100644 index 00000000..476a2f42 --- /dev/null +++ b/app/services/change_karma.py @@ -0,0 +1,19 @@ +from app.models import User, Chat, UserKarma, KarmaEvent + + +async def change_karma(user: User, target_user: User, chat: Chat, how_change: float, comment: str = ""): + uk, power = await UserKarma.change_or_create( + target_user=target_user, + chat=chat, + user_changed=user, + how_change=how_change + ) + await KarmaEvent( + user_from=user, + user_to=target_user, + chat=chat, + how_change=how_change, + how_match_change=power*abs(how_change), + coment=comment + ).save() + return uk, power diff --git a/app/services/user_info.py b/app/services/user_info.py new file mode 100644 index 00000000..bed8492b --- /dev/null +++ b/app/services/user_info.py @@ -0,0 +1,9 @@ +from app.models import User, Chat, ModeratorEvent, KarmaEvent + + +async def get_user_info(user: User, chat: Chat): + moderation_events = await ModeratorEvent.get_last_by_user(user, chat) + karma_events = await KarmaEvent.get_last_by_user(user, chat) + rez = [(event.date, event.format_event()) for event in [*moderation_events, *karma_events]] + rez.sort(key=lambda t: t[0]) + return [elem[1] for elem in rez] diff --git a/app/utils/exceptions.py b/app/utils/exceptions.py index c7c97885..5fd0c9d4 100644 --- a/app/utils/exceptions.py +++ b/app/utils/exceptions.py @@ -39,3 +39,15 @@ class SubZeroKarma(KarmaError): class CantImportFromAxenia(KarmaError): pass + + +class TimedeltaParseError(KarmaError): + pass + + +class ToLongDuration(TimedeltaParseError): + pass + + +class InvalidFormatDuration(TimedeltaParseError): + pass diff --git a/app/utils/timedelta_functions.py b/app/utils/timedelta_functions.py index 87f3cd20..93d17f69 100644 --- a/app/utils/timedelta_functions.py +++ b/app/utils/timedelta_functions.py @@ -2,7 +2,7 @@ import re import typing -from aiogram import types +from app.utils.exceptions import TimedeltaParseError, ToLongDuration, InvalidFormatDuration MODIFIERS = { "y": datetime.timedelta(days=365), # простим один день если кому-то попадётся високосный @@ -15,17 +15,12 @@ ALL_MODIFIER = "".join(MODIFIERS.keys()) PATTERN = re.compile(rf"(?P\d+)(?P[{ALL_MODIFIER}])") LINE_PATTERN = re.compile(rf"^(\d+[{ALL_MODIFIER}])+$") -DEFAULT_TIME_DELTA = datetime.timedelta(hours=1) - - -class TimedeltaParseError(Exception): - pass def parse_timedelta(value: str) -> datetime.timedelta: match = LINE_PATTERN.match(value) if not match: - raise TimedeltaParseError("Invalid time format") + raise InvalidFormatDuration("Invalid time format") try: result = datetime.timedelta() @@ -34,27 +29,19 @@ def parse_timedelta(value: str) -> datetime.timedelta: result += int(value) * MODIFIERS[modifier] except OverflowError: - raise TimedeltaParseError("Timedelta value is too large") + raise ToLongDuration("Timedelta value is too large") return result -async def parse_timedelta_from_message( - message: types.Message -) -> typing.Optional[datetime.timedelta]: - _, *args = message.text.split() +def parse_timedelta_from_text(text_duration: str) -> typing.Optional[datetime.timedelta]: + if not text_duration: + return None - if args: # Parse custom duration - try: - duration = parse_timedelta(args[0]) - except TimedeltaParseError: - await message.reply("Failed to parse duration") - return - if duration <= datetime.timedelta(seconds=30): - return datetime.timedelta(seconds=30) - return duration - else: - return DEFAULT_TIME_DELTA + duration = parse_timedelta(text_duration) + if duration <= datetime.timedelta(seconds=30): + return datetime.timedelta(seconds=30) + return duration def format_timedelta(td: datetime.timedelta) -> str: