From 13a23fc2fd5c6bec4cc273194a10ed47ef852b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9?= Date: Fri, 27 Nov 2020 11:09:40 +0300 Subject: [PATCH] Restrict on negative karma (#72) * add transaction to change_karma services * split ban and ro to handler part and service part * fixes, separate logic between functions, refactoring * add __all__ for linter fix * add automatically restriction after very low karma * fixes * add config param to enable/disable auto restrict on negative karma * move render message in separate function * move condition to separate function * move TypeRestriction to models package * rename variable * add function for check has user restriction or not * fix exception clause * fix typehint (change destination in pyrogram) * add auto restrict on negative karma * last one restriction on negative karma must be ban * text fixes * update .gitignore * fix texts * refactor more logic karmic ro * user cant negative karma to target with RO new exception and Exception group - for skip negative karma process remove deprecated filters * BUGFIXES, fix cancel change karma * correct shield restricted from decrease karma and notify user for that * fix typos remove unused comments remove unused imports * fix race condition in case one user change karma and make it less than -100, bot change karma to -80 than another user change same karma and than first user cancel his action. Now it case correct work cancel imitate that user newer do cancelled action * rename consts * add few info to README.md Co-authored-by: Yuriy Chebyshev --- .gitignore | 2 + README.md | 8 +- app/config.py | 55 ++++++++++- app/handlers/change_karma.py | 77 ++++++++++++--- app/handlers/karma.py | 5 +- app/handlers/keyboards.py | 15 ++- app/handlers/moderator.py | 96 +++--------------- app/models/common.py | 9 ++ app/models/moderator_actions.py | 8 +- app/models/user.py | 17 +++- app/models/user_karma.py | 24 +++-- app/services/change_karma.py | 141 ++++++++++++++++++--------- app/services/find_target_user.py | 4 +- app/services/moderation.py | 161 ++++++++++++++++++++++++++++++- app/services/user_getter.py | 4 +- app/utils/exceptions.py | 23 ++++- app/utils/from_axenia.py | 2 +- app/utils/types.py | 12 +++ tests/karma/common.py | 6 ++ 19 files changed, 497 insertions(+), 172 deletions(-) create mode 100644 app/models/common.py create mode 100644 app/utils/types.py diff --git a/.gitignore b/.gitignore index 8448a14b..d73d7c11 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ log/ */.pytest_cache/ jsons/ *.session +__pycache__/ +db_data/ diff --git a/README.md b/README.md index 57cdddd0..46e2538d 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ to the group administrators * !idchat - get id of chat, your id, and id of replayed user moderator commands list: -* !ro !mute [DURATION] - restrict replyed user for DURATION. -* !ban [DURATION] - kick replyed user for DURATION +* !ro !mute [DURATION] [@mention] - restrict replied or mentioned user for DURATION. +* !ban [DURATION] [@mention] - kick replied user for DURATION * DURATION in format [AAAy][BBBw][CCCd][DDDh][EEEm][FFFs] where: * AAA - count of years (more that one years is permanent) * BBB - count of weeks @@ -30,8 +30,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) +* !warn, !w [@mention] - official warn user from moderator +* !info [@mention] - 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 622403dc..568eb845 100644 --- a/app/config.py +++ b/app/config.py @@ -3,17 +3,33 @@ """ import os import secrets +import typing +from datetime import timedelta +from functools import partial from pathlib import Path +from aiogram import Bot from dotenv import load_dotenv +from app.models.common import TypeRestriction + app_dir: Path = Path(__file__).parent.parent load_dotenv(str(app_dir / '.env')) PLUS = "+" -PLUS_WORDS = frozenset( - {"спасибо", "спс", "спасибочки", "благодарю", "пасиба", "пасеба", "посеба", "благодарочка", "thx", "мерси", "выручил"} -) +PLUS_WORDS = frozenset({ + "спасибо", + "спс", + "спасибочки", + "благодарю", + "пасиба", + "пасеба", + "посеба", + "благодарочка", + "thx", + "мерси", + "выручил", +}) PLUS_TRIGGERS = frozenset({PLUS, *PLUS_WORDS}) PLUS_EMOJI = frozenset({"👍", }) MINUS = "-" @@ -22,6 +38,39 @@ TIME_TO_CANCEL_ACTIONS = 60 +DEFAULT_RESTRICT_DURATION = timedelta(hours=1) +FOREVER_RESTRICT_DURATION = timedelta(days=666) + +# auto restrict when karma less than NEGATIVE_KARMA_TO_RESTRICT +ENABLE_AUTO_RESTRICT_ON_NEGATIVE_KARMA = bool(int(os.getenv( + "ENABLE_AUTO_RESTRICT_ON_NEGATIVE_KARMA", default=0))) + +NEGATIVE_KARMA_TO_RESTRICT = -100 +KARMA_AFTER_RESTRICT = -80 + + +class RestrictionPlanElem(typing.NamedTuple): + duration: timedelta + type_restriction: TypeRestriction + + +RESTRICTIONS_PLAN: typing.List[RestrictionPlanElem] = [ + RestrictionPlanElem(timedelta(days=7), TypeRestriction.karmic_ro), + RestrictionPlanElem(timedelta(days=30), TypeRestriction.karmic_ro), + RestrictionPlanElem(FOREVER_RESTRICT_DURATION, TypeRestriction.karmic_ban), +] + +RO_ACTION = partial(Bot.restrict_chat_member, can_send_messages=False) +BAN_ACTION = Bot.kick_chat_member + +action_for_restrict = { + TypeRestriction.ban: BAN_ACTION, + TypeRestriction.ro: RO_ACTION, + TypeRestriction.karmic_ro: RO_ACTION, + TypeRestriction.karmic_ban: BAN_ACTION, +} +COMMENT_AUTO_RESTRICT = f"Карма ниже {NEGATIVE_KARMA_TO_RESTRICT}" + PROG_NAME = "KarmaBot" PROG_DESC = ( "This program is a Python 3+ script. The script launches a bot in Telegram," diff --git a/app/handlers/change_karma.py b/app/handlers/change_karma.py index c8039fff..fdfedf79 100644 --- a/app/handlers/change_karma.py +++ b/app/handlers/change_karma.py @@ -3,16 +3,18 @@ from aiogram import types from aiogram.types import ContentType -from aiogram.utils.markdown import hbold +from loguru import logger from app.misc import dp from app import config from app.models import Chat, User from app.services.change_karma import change_karma, cancel_karma_change -from app.utils.exceptions import SubZeroKarma, AutoLike +from app.utils.exceptions import SubZeroKarma, CantChangeKarma, DontOffendRestricted from app.services.remove_message import remove_kb_after_sleep from . import keyboards as kb -from ..services.adaptive_trottle import AdaptiveThrottle +from app.services.adaptive_trottle import AdaptiveThrottle +from app.services.moderation import it_was_last_one_auto_restrict +from app.utils.timedelta_functions import format_timedelta a_throttle = AdaptiveThrottle() @@ -37,35 +39,82 @@ async def too_fast_change_karma(message: types.Message, *_, **__): async def karma_change(message: types.Message, karma: dict, user: User, chat: Chat, target: User): try: - uk, abs_change, karma_event = await change_karma( + result_change_karma = await change_karma( target_user=target, chat=chat, user=user, how_change=karma['karma_change'], - comment=karma['comment'] + comment=karma['comment'], + bot=message.bot, ) except SubZeroKarma: return await message.reply("У Вас слишком мало кармы для этого") - except AutoLike: + except DontOffendRestricted: + return await message.reply("Не обижай его, он и так наказан!") + except CantChangeKarma as e: + logger.info("user {user} can't change karma, {e}", user=user.tg_id, e=e) return + if result_change_karma.count_auto_restrict: + notify_auto_restrict_text = await render_text_auto_restrict(result_change_karma.count_auto_restrict, target) + else: + notify_auto_restrict_text = "" + + # How match karma was changed. Sign show changed difference, not difference for cancel + how_changed_karma = result_change_karma.user_karma.karma \ + - result_change_karma.karma_after \ + + result_change_karma.abs_change msg = await message.reply( - "Вы {how_change} карму {name} до {karma_new} ({power:+.2f})".format( + "Вы {how_change} карму {name} до {karma_new:.2f} ({power:+.2f})" + "\n\n{notify_auto_restrict_text}".format( how_change=get_how_change_text(karma['karma_change']), - name=hbold(target.fullname), - karma_new=hbold(uk.karma_round), - power=abs_change, + name=target.fullname, + karma_new=result_change_karma.karma_after, + power=result_change_karma.abs_change, + notify_auto_restrict_text=notify_auto_restrict_text ), disable_web_page_preview=True, - reply_markup=kb.get_kb_karma_cancel(user, karma_event) + reply_markup=kb.get_kb_karma_cancel( + user=user, + karma_event=result_change_karma.karma_event, + rollback_karma=-how_changed_karma, + moderator_event=result_change_karma.moderator_event, + ) ) asyncio.create_task(remove_kb_after_sleep(msg, config.TIME_TO_CANCEL_ACTIONS)) +async def render_text_auto_restrict(count_auto_restrict: int, target: User): + # TODO чото надо сделать с этим чтобы понятно объяснить за что RO и что будет в следующий раз + text = "{target}, Уровень вашей кармы стал ниже {negative_limit}.\n".format( + target=target.mention_link, + negative_limit=config.NEGATIVE_KARMA_TO_RESTRICT, + ) + if it_was_last_one_auto_restrict(count_auto_restrict): + text += "Это был последний разрешённый раз. Теперь вы получаете вечное наказание." + else: + text += ( + "За это вы наказаны на срок {duration}\n" + "Вам установлена карма {karma_after}. " + "Если Ваша карма снова достигнет {karma_to_restrict} " + "Ваше наказание будет строже.".format( + duration=format_timedelta(config.RESTRICTIONS_PLAN[count_auto_restrict - 1].duration), + karma_after=config.KARMA_AFTER_RESTRICT, + karma_to_restrict=config.NEGATIVE_KARMA_TO_RESTRICT, + ) + ) + return text + + @dp.callback_query_handler(kb.cb_karma_cancel.filter()) async def cancel_karma(callback_query: types.CallbackQuery, callback_data: typing.Dict[str, str]): - if int(callback_data['user_id']) != callback_query.from_user.id: - return await callback_query.answer("Эта кнопка не для вас", cache_time=3600) - await cancel_karma_change(callback_data['action_id']) + user_cancel_id = int(callback_data['user_id']) + if user_cancel_id != callback_query.from_user.id: + return await callback_query.answer("Эта кнопка не для Вас", cache_time=3600) + karma_event_id = int(callback_data['karma_event_id']) + rollback_karma = float(callback_data['rollback_karma']) + moderator_event_id = callback_data['moderator_event_id'] + moderator_event_id = None if moderator_event_id == "null" else int(moderator_event_id) + await cancel_karma_change(karma_event_id, rollback_karma, moderator_event_id, callback_query.bot) await callback_query.answer("Вы отменили изменение кармы", show_alert=True) await callback_query.message.delete() diff --git a/app/handlers/karma.py b/app/handlers/karma.py index 851a9996..6a1e42ec 100644 --- a/app/handlers/karma.py +++ b/app/handlers/karma.py @@ -17,7 +17,6 @@ async def get_top_from_private(message: types.Message, chat: Chat, user: User): logger.info("user {user} ask top karma of chat {chat}", user=user.tg_id, chat=chat.chat_id) if len(parts) > 1: chat = await Chat.get(chat_id=int(parts[1])) - else: return await message.reply( "Эту команду можно использовать только в группах " "или с указанием id нужного чата, например:" @@ -37,7 +36,7 @@ async def get_top(message: types.Message, chat: Chat, user: User): await message.reply(text, disable_web_page_preview=True) -@dp.message_handler(ChatType.is_group_or_super_group, commands=["me"], commands_prefix='!') +@dp.message_handler(chat_type=[ChatType.GROUP, ChatType.SUPERGROUP], commands=["me"], commands_prefix='!') @dp.throttled(rate=15) async def get_top(message: types.Message, chat: Chat, user: User): logger.info("user {user} ask his karma in chat {chat}", user=user.tg_id, chat=chat.chat_id) @@ -45,7 +44,7 @@ async def get_top(message: types.Message, chat: Chat, user: User): await message.reply(f"Ваша карма в данном чате: {uk.karma_round}", disable_web_page_preview=True) -@dp.message_handler(ChatType.is_private, commands=["me"], commands_prefix='!') +@dp.message_handler(chat_type=ChatType.PRIVATE, commands=["me"], commands_prefix='!') @dp.throttled(rate=15) async def get_top(message: types.Message, user: User): logger.info("user {user} ask his karma", user=user.tg_id) diff --git a/app/handlers/keyboards.py b/app/handlers/keyboards.py index ee9beb0b..eb93dd23 100644 --- a/app/handlers/keyboards.py +++ b/app/handlers/keyboards.py @@ -1,12 +1,19 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from aiogram.utils.callback_data import CallbackData -from app.models import User, KarmaEvent +from app.models import User, KarmaEvent, ModeratorEvent -cb_karma_cancel = CallbackData("karma_cancel", "user_id", "action_id") +cb_karma_cancel = CallbackData("karma_cancel", "user_id", "karma_event_id", "rollback_karma", "moderator_event_id") -def get_kb_karma_cancel(user: User, action: KarmaEvent) -> InlineKeyboardMarkup: +def get_kb_karma_cancel( + user: User, karma_event: KarmaEvent, rollback_karma: float, moderator_event: ModeratorEvent +) -> InlineKeyboardMarkup: return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton( - "Отменить", callback_data=cb_karma_cancel.new(user_id=user.tg_id, action_id=action.id_) + "Отменить", callback_data=cb_karma_cancel.new( + user_id=user.tg_id, + karma_event_id=karma_event.id_, + rollback_karma=f"{rollback_karma:.2f}", + moderator_event_id=moderator_event.id_ if moderator_event is not None else "null", + ) )]]) diff --git a/app/handlers/moderator.py b/app/handlers/moderator.py index 73aa7479..84296557 100644 --- a/app/handlers/moderator.py +++ b/app/handlers/moderator.py @@ -1,25 +1,18 @@ -import typing -from datetime import timedelta - from aiogram import types -from aiogram.utils.exceptions import BadRequest, Unauthorized +from aiogram.utils.exceptions import Unauthorized from aiogram.utils.markdown import hide_link, quote_html from loguru import logger 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.utils.exceptions import TimedeltaParseError, ModerationError +from app.models import Chat, User from app.services.user_info import get_user_info -from app.services.moderation import warn_user +from app.services.moderation import warn_user, ro_user, ban_user, get_duration from app.services.remove_message import delete_message -FOREVER_DURATION = timedelta(days=366) -DEFAULT_DURATION = timedelta(hours=1) - @dp.message_handler( - types.ChatType.is_group_or_super_group, + chat_type=[types.ChatType.GROUP, types.ChatType.SUPERGROUP], is_reply=True, commands=['report', 'admin'], commands_prefix="/!@", @@ -47,25 +40,6 @@ 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 like: "!ro 13d don't flood" - if not args: - return "", "" - duration_text = args[0] - if len(args) == 1: - return duration_text, "" - return duration_text, " ".join(args[1:]) - - -def get_duration(text: str): - duration_text, comment = get_moderator_message_args(text) - if duration_text: - duration = parse_timedelta_from_text(duration_text) - else: - duration = DEFAULT_DURATION - return duration, comment - - @dp.message_handler( commands=["ro", "mute"], commands_prefix="!", @@ -73,39 +47,18 @@ def get_duration(text: str): user_can_restrict_members=True, bot_can_restrict_members=True, ) -async def cmd_ro(message: types.Message, chat: Chat, user: User, target: User): +async def cmd_ro(message: types.Message, user: User, target: User, chat: Chat): try: duration, comment = get_duration(message.text) except TimedeltaParseError as e: return await message.reply(f"Не могу распознать время. {quote_html(e.text)}") try: - await message.chat.restrict(target.tg_id, can_send_messages=False, until_date=duration) - logger.info( - "User {user} restricted by {admin} for {duration}", - user=target.tg_id, - admin=user.tg_id, - duration=duration, - ) - except BadRequest as e: + success_text = await ro_user(chat, target, user, duration, comment, message.bot) + except ModerationError as e: logger.error("Failed to restrict chat member: {error!r}", error=e) - return False else: - await ModeratorEvent.save_new_action( - moderator=user, - user=target, - chat=chat, - type_restriction="ro", - duration=duration, - comment=comment - ) - - await message.reply( - "Пользователь {user} сможет только читать сообщения на протяжении {duration}".format( - user=target.mention_link, - duration=format_timedelta(duration), - ) - ) + await message.reply(success_text) @dp.message_handler( @@ -115,39 +68,18 @@ async def cmd_ro(message: types.Message, chat: Chat, user: User, target: User): user_can_restrict_members=True, bot_can_restrict_members=True, ) -async def cmd_ban(message: types.Message, chat: Chat, user: User, target: User): +async def cmd_ban(message: types.Message, user: User, target: User, chat: Chat): try: duration, comment = get_duration(message.text) except TimedeltaParseError as e: return await message.reply(f"Не могу распознать время. {quote_html(e.text)}") try: - await message.chat.kick(target.tg_id, until_date=duration) - logger.info( - "User {user} kicked by {admin} for {duration}", - user=target.tg_id, - admin=user.tg_id, - duration=duration, - ) - except BadRequest as e: + success_text = await ban_user(chat, target, user, duration, comment, message.bot) + except ModerationError as e: logger.error("Failed to kick chat member: {error!r}", error=e) - return False else: - await ModeratorEvent.save_new_action( - moderator=user, - user=target, - chat=chat, - type_restriction="ban", - duration=duration, - comment=comment - ) - - text = "Пользователь {user} попал в бан этого чата.".format( - user=target.mention_link, - ) - if duration < FOREVER_DURATION: - text += " Он сможет вернуться через {duration}".format(duration=format_timedelta(duration)) - await message.reply(text) + await message.reply(success_text) @dp.message_handler( @@ -187,7 +119,7 @@ async def get_info_about_user(message: types.Message, chat: Chat, target: User): disable_web_page_preview=True ) except Unauthorized: - await message.reply("Напишите мне в личку /start и повторите команду.") + await message.reply(f"{message.from_user.get_mention()}, напишите мне в личку /start и повторите команду.") finally: await delete_message(message) diff --git a/app/models/common.py b/app/models/common.py new file mode 100644 index 00000000..0c474a9b --- /dev/null +++ b/app/models/common.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class TypeRestriction(Enum): + ro = "ro" + ban = "ban" + warn = "warn" + karmic_ro = "karmic_ro" + karmic_ban = "karmic_ban" diff --git a/app/models/moderator_actions.py b/app/models/moderator_actions.py index 86d7e7b6..cd269c38 100644 --- a/app/models/moderator_actions.py +++ b/app/models/moderator_actions.py @@ -28,8 +28,9 @@ class Meta: table = 'moderator_events' def __repr__(self): + # noinspection PyUnresolvedReferences return ( - f"KarmaEvent {self.id_} from moderator {self.moderator.id} to {self.user.id}, date {self.date}, " + f"ModeratorEvent {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}" ) @@ -41,7 +42,8 @@ async def save_new_action( chat: Chat, type_restriction: str, duration: timedelta = None, - comment: str = "" + comment: str = "", + using_db=None, ): moderator_event = ModeratorEvent( moderator=moderator, @@ -51,7 +53,7 @@ async def save_new_action( timedelta_restriction=duration, comment=comment ) - await moderator_event.save() + await moderator_event.save(using_db=using_db) return moderator_event @classmethod diff --git a/app/models/user.py b/app/models/user.py index bd9caacb..6dbb391c 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,3 +1,5 @@ +from datetime import datetime + from aiogram import types from aiogram.utils.markdown import hlink, quote_html from tortoise import fields @@ -6,6 +8,7 @@ from app.utils.exceptions import UserWithoutUserIdError from .chat import Chat +from .common import TypeRestriction class User(Model): @@ -17,6 +20,8 @@ class User(Model): is_bot: bool = fields.BooleanField(null=True) # noinspection PyUnresolvedReferences karma: fields.ReverseRelation['UserKarma'] + # noinspection PyUnresolvedReferences + my_restriction_events: fields.ReverseRelation['ModeratorEvent'] class Meta: table = "users" @@ -64,7 +69,6 @@ async def update_user_data(self, user_tg): async def get_or_create_from_tg_user(cls, user_tg: types.User): if user_tg.id is None: try: - # __iexact - хотя в документации __iequals return await cls.get(username__iexact=user_tg.username) except DoesNotExist: raise UserWithoutUserIdError(username=user_tg.username) @@ -116,6 +120,17 @@ async def get_number_in_top_karma(self, chat: Chat) -> int: uk: "UserKarma" = await self.get_uk(chat) return await uk.number_in_top() + async def has_now_ro_db(self, chat: Chat): + my_restrictions = await self.my_restriction_events.filter( + chat=chat, + type_restriction=TypeRestriction.ro.name + ).all() + for my_restriction in my_restrictions: + if my_restriction.timedelta_restriction \ + and my_restriction.date + my_restriction.timedelta_restriction > datetime.now(): + return True + return False + def to_json(self): return dict( id=self.id, diff --git a/app/models/user_karma.py b/app/models/user_karma.py index 6e5dda2d..9096b982 100644 --- a/app/models/user_karma.py +++ b/app/models/user_karma.py @@ -31,14 +31,14 @@ def __str__(self): def __repr__(self): return str(self) - async def change(self, user_changed: User, how_change: float): + async def change(self, user_changed: User, how_change: float, using_db=None): """ change karma to (self.user) from (user_changed) (how_change) must be from -inf to +inf """ if how_change == 0: raise ValueError(f"how_change must be float and not 0 but it is {how_change}") - await self.fetch_related('chat') + await self.fetch_related('chat', using_db=using_db) power = await self.get_power(user_changed, self.chat) if power < 0.01: logger.info("user {user} try to change karma but have negative karma", user=user_changed.tg_id) @@ -50,21 +50,33 @@ async def change(self, user_changed: User, how_change: float): change_sign = +1 if how_change > 0 else -1 abs_how_change = min(abs(how_change), power) self.karma = self.karma + change_sign * abs_how_change - await self.save(update_fields=["karma"]) + await self.save(update_fields=["karma"], using_db=using_db) relative_power = abs_how_change / power return change_sign * abs_how_change, change_sign * relative_power @classmethod - async def change_or_create(cls, target_user: User, chat: Chat, user_changed: User, how_change: float): + async def change_or_create( + cls, + target_user: User, + chat: Chat, + user_changed: User, + how_change: float, + using_db=None, + ): """ change karma to (target_user) from (user_changed) in (chat) (how_change) must be from -inf to +inf """ uk, _ = await UserKarma.get_or_create( user=target_user, - chat=chat + chat=chat, + using_db=using_db, + ) + abs_change, relative_change = await uk.change( + user_changed=user_changed, + how_change=how_change, + using_db=using_db, ) - abs_change, relative_change = await uk.change(user_changed=user_changed, how_change=how_change) return uk, abs_change, relative_change @classmethod diff --git a/app/services/change_karma.py b/app/services/change_karma.py index 193ba900..35fcd51b 100644 --- a/app/services/change_karma.py +++ b/app/services/change_karma.py @@ -1,56 +1,111 @@ +from aiogram import Bot from loguru import logger +from tortoise.transactions import in_transaction -from app.models import User, Chat, UserKarma, KarmaEvent -from app.utils.exceptions import AutoLike +from app import config +from app.models import User, Chat, UserKarma, KarmaEvent, ModeratorEvent +from app.models.common import TypeRestriction +from app.services.moderation import auto_restrict, check_need_auto_restrict, user_has_now_ro +from app.utils.exceptions import AutoLike, DontOffendRestricted +from app.utils.types import ResultChangeKarma def can_change_karma(target_user: User, user: User): return user.id != target_user.id and not target_user.is_bot -async def change_karma(user: User, target_user: User, chat: Chat, how_change: float, comment: str = ""): +async def change_karma(user: User, target_user: User, chat: Chat, how_change: float, bot: Bot, comment: str = ""): if not can_change_karma(target_user, user): logger.info("user {user} try to change self or bot karma ", user=user.tg_id) raise AutoLike(user_id=user.tg_id, chat_id=chat.chat_id) - uk, abs_change, relative_change = await UserKarma.change_or_create( - target_user=target_user, - chat=chat, - user_changed=user, - how_change=how_change - ) - ke = KarmaEvent( - user_from=user, - user_to=target_user, - chat=chat, - how_change=relative_change, - how_change_absolute=abs_change, - comment=comment - ) - await ke.save() - logger.info( - "user {user} change karma of {target_user} in chat {chat}", - user=user.tg_id, - target_user=target_user.tg_id, - chat=chat.chat_id + if how_change < 0 and await user_has_now_ro(target_user, chat, bot): + logger.info("user {user} try to change karma of another user {target} with RO ", + user=user.tg_id, target=target_user.tg_id) + raise DontOffendRestricted(user_id=user.tg_id, chat_id=chat.chat_id) + + async with in_transaction() as conn: + uk, abs_change, relative_change = await UserKarma.change_or_create( + target_user=target_user, + chat=chat, + user_changed=user, + how_change=how_change, + using_db=conn, + ) + ke = KarmaEvent( + user_from=user, + user_to=target_user, + chat=chat, + how_change=relative_change, + how_change_absolute=abs_change, + comment=comment, + ) + await ke.save(using_db=conn) + logger.info( + "user {user} change karma of {target_user} in chat {chat}", + user=user.tg_id, + target_user=target_user.tg_id, + chat=chat.chat_id + ) + karma_after = uk.karma + + if check_need_auto_restrict(uk.karma): + count_auto_restrict, moderator_event = await auto_restrict( + bot=bot, + chat=chat, + target=target_user, + using_db=conn, + ) + uk.karma = config.KARMA_AFTER_RESTRICT + await uk.save(using_db=conn) + else: + count_auto_restrict = 0 + moderator_event = None + + return ResultChangeKarma( + user_karma=uk, + abs_change=abs_change, + karma_event=ke, + count_auto_restrict=count_auto_restrict, + karma_after=karma_after, + moderator_event=moderator_event ) - return uk, abs_change, ke - - -async def cancel_karma_change(karma_event_id): - karma_event = await KarmaEvent.get(id_=karma_event_id) - - # noinspection PyUnresolvedReferences - user_to_id = karma_event.user_to_id - # noinspection PyUnresolvedReferences - user_from_id = karma_event.user_from_id - # noinspection PyUnresolvedReferences - chat_id = karma_event.chat_id - - user_karma = await UserKarma.get(chat_id=chat_id, user_id=user_to_id) - user_karma.karma -= karma_event.how_change_absolute - await user_karma.save(update_fields=['karma']) - await karma_event.delete() - logger.info( - "user {user} cancel change karma to user {target} in chat {chat}", - user=user_from_id, target=user_to_id, chat=chat_id) + + +async def cancel_karma_change(karma_event_id: int, rollback_karma: float, moderator_event_id: int, bot: Bot): + async with in_transaction() as conn: + karma_event = await KarmaEvent.get(id_=karma_event_id) + + # noinspection PyUnresolvedReferences + user_to_id = karma_event.user_to_id + # noinspection PyUnresolvedReferences + user_from_id = karma_event.user_from_id + # noinspection PyUnresolvedReferences + chat_id = karma_event.chat_id + + user_karma = await UserKarma.get(chat_id=chat_id, user_id=user_to_id) + user_karma.karma = user_karma.karma + rollback_karma + await user_karma.save(update_fields=['karma'], using_db=conn) + await karma_event.delete(using_db=conn) + if moderator_event_id is not None: + moderator_event = await ModeratorEvent.get(id_=moderator_event_id) + restricted_user = await User.get(id=user_to_id) + + if moderator_event.type_restriction == TypeRestriction.karmic_ro.name: + await bot.restrict_chat_member( + chat_id=chat_id, + user_id=restricted_user.tg_id, + can_send_messages=True, + can_send_media_messages=True, + can_add_web_page_previews=True, + can_send_other_messages=True, + ) + + elif moderator_event.type_restriction == TypeRestriction.karmic_ban.name: + await bot.unban_chat_member(chat_id=chat_id, user_id=restricted_user.tg_id, only_if_banned=True) + + await moderator_event.delete(using_db=conn) + + logger.info( + "user {user} cancel change karma to user {target} in chat {chat}", + user=user_from_id, target=user_to_id, chat=chat_id) diff --git a/app/services/find_target_user.py b/app/services/find_target_user.py index cef39b10..a45a2d45 100644 --- a/app/services/find_target_user.py +++ b/app/services/find_target_user.py @@ -21,7 +21,7 @@ def get_target_user(message: types.Message, can_be_same=False, can_be_bot=False) author_user = message.from_user - target_user = get_replyed_user(message) + target_user = get_replied_user(message) if has_target_user(target_user, author_user, can_be_same, can_be_bot): return target_user @@ -78,7 +78,7 @@ def get_mentioned_user(message: types.Message) -> typing.Optional[types.User]: return None -def get_replyed_user(message: types.Message) -> typing.Optional[types.User]: +def get_replied_user(message: types.Message) -> typing.Optional[types.User]: if message.reply_to_message: return message.reply_to_message.from_user return None diff --git a/app/services/moderation.py b/app/services/moderation.py index 56591422..3e90832e 100644 --- a/app/services/moderation.py +++ b/app/services/moderation.py @@ -1,4 +1,15 @@ +import typing +from datetime import timedelta + +from aiogram import Bot +from aiogram.utils.exceptions import BadRequest +from loguru import logger + +from app import config from app.models import ModeratorEvent, User, Chat +from app.models.common import TypeRestriction +from app.utils.exceptions import CantRestrict +from app.utils.timedelta_functions import parse_timedelta_from_text, format_timedelta async def warn_user(moderator: User, target_user: User, chat: Chat, comment: str): @@ -6,7 +17,153 @@ async def warn_user(moderator: User, target_user: User, chat: Chat, comment: str moderator=moderator, user=target_user, chat=chat, - type_restriction="warn", - comment=comment + type_restriction=TypeRestriction.warn.name, + comment=comment, + ) + + +async def ban_user(chat: Chat, target: User, admin: User, duration: timedelta, comment: str, bot: Bot): + await restrict( + bot=bot, + chat=chat, + target=target, + admin=admin, + duration=duration, + comment=comment, + type_restriction=TypeRestriction.ban + ) + text = "Пользователь {user} попал в бан этого чата.".format(user=target.mention_link) + if duration < config.FOREVER_RESTRICT_DURATION: + text += " Он сможет вернуться через {duration}".format(duration=format_timedelta(duration)) + return text + + +async def ro_user(chat: Chat, target: User, admin: User, duration: timedelta, comment: str, bot: Bot): + await restrict( + bot=bot, + chat=chat, + target=target, + admin=admin, + duration=duration, + comment=comment, + type_restriction=TypeRestriction.ro, + ) + return "Пользователь {user} сможет только читать сообщения на протяжении {duration}".format( + user=target.mention_link, + duration=format_timedelta(duration), + ) + + +async def restrict( + bot: Bot, + chat: Chat, + target: User, + admin: User, + duration: timedelta, + comment: str, + type_restriction: TypeRestriction, + using_db=None +): + try: + # restrict in telegram + await config.action_for_restrict[type_restriction]( + bot, + chat_id=chat.chat_id, + user_id=target.tg_id, + until_date=duration, + ) + except BadRequest as e: + raise CantRestrict( + text=e.text, user_id=target.tg_id, chat_id=chat.chat_id, + reason=comment, type_event=type_restriction.name + ) + else: + moderator_event = await ModeratorEvent.save_new_action( + moderator=admin, + user=target, + chat=chat, + type_restriction=type_restriction.name, + duration=duration, + comment=comment, + using_db=using_db, + ) + logger.info( + "User {user} restricted ({type_restriction}) by {admin} for {duration} in chat {chat}", + user=target.tg_id, + type_restriction=type_restriction.name, + admin=admin.tg_id, + duration=duration, + chat=chat.chat_id, + ) + return moderator_event + + +def get_moderator_message_args(text: str) -> typing.Tuple[str, str]: + _, *args = text.split(maxsplit=2) # in text: command_duration_comments like: "!ro 13d don't flood" + if not args: + return "", "" + duration_text = args[0] + if len(args) == 1: + return duration_text, "" + return duration_text, " ".join(args[1:]) + + +def get_duration(text: str): + duration_text, comment = get_moderator_message_args(text) + if duration_text: + duration = parse_timedelta_from_text(duration_text) + else: + duration = config.DEFAULT_RESTRICT_DURATION + return duration, comment + + +def check_need_auto_restrict(karma: float): + return all([ + config.ENABLE_AUTO_RESTRICT_ON_NEGATIVE_KARMA, + karma <= config.NEGATIVE_KARMA_TO_RESTRICT, + ]) + + +async def user_has_now_ro(user: User, chat: Chat, bot: Bot): + chat_member = await bot.get_chat_member(chat_id=chat.chat_id, user_id=user.tg_id) + return chat_member.can_send_messages is False + + +async def auto_restrict(target: User, chat: Chat, bot: Bot, using_db=None) -> typing.Tuple[int, ModeratorEvent]: + """ + return count auto restrict + """ + bot_user = await User.get_or_create_from_tg_user(await bot.me) + + count_auto_restrict = await ModeratorEvent.filter( + moderator=bot_user, user=target, chat=chat, + type_restriction__in=(TypeRestriction.karmic_ro.name, TypeRestriction.karmic_ban.name), + ).count() + logger.info( + "auto restrict user {user} in chat {chat} for to negative karma. " + "previous restrict count: {count}", + user=target.tg_id, + chat=chat.chat_id, + count=count_auto_restrict, + ) + + if it_was_last_one_auto_restrict(count_auto_restrict): + current_restriction = config.RESTRICTIONS_PLAN[-1] + else: + current_restriction = config.RESTRICTIONS_PLAN[count_auto_restrict] + + moderator_event = await restrict( + bot=bot, + chat=chat, + target=target, + admin=bot_user, + duration=current_restriction.duration, + comment=config.COMMENT_AUTO_RESTRICT, + type_restriction=current_restriction.type_restriction, + using_db=using_db, ) + return count_auto_restrict + 1, moderator_event + +def it_was_last_one_auto_restrict(count_auto_restrict: int) -> bool: + return count_auto_restrict >= len(config.RESTRICTIONS_PLAN) diff --git a/app/services/user_getter.py b/app/services/user_getter.py index b796c5a7..01b82cb0 100644 --- a/app/services/user_getter.py +++ b/app/services/user_getter.py @@ -70,7 +70,7 @@ async def get_user_by_fullname(self, chat_id: int, fullname: str) -> User: return self.get_aiogram_user_by_pyrogram(user) @staticmethod - def get_aiogram_user_by_pyrogram(user: pyrogram.User) -> User: + def get_aiogram_user_by_pyrogram(user: pyrogram.types.User) -> User: return User( id=user.id, is_bot=user.is_bot, @@ -81,7 +81,7 @@ def get_aiogram_user_by_pyrogram(user: pyrogram.User) -> User: ) @staticmethod - def get_user_dict_for_log(user: pyrogram.User) -> dict: + def get_user_dict_for_log(user: pyrogram.types.User) -> dict: return dict( id=user.id, is_bot=user.is_bot, diff --git a/app/utils/exceptions.py b/app/utils/exceptions.py index faaa7a40..ff5f7384 100644 --- a/app/utils/exceptions.py +++ b/app/utils/exceptions.py @@ -23,6 +23,10 @@ def __repr__(self): return str(self) +class CantChangeKarma(KarmaError): + pass + + class UserWithoutUserIdError(KarmaError): def __init__(self, username: str = None, **kwargs): super().__init__(**kwargs) @@ -33,11 +37,15 @@ def __init__(self, username: str = None, **kwargs): ) -class SubZeroKarma(KarmaError): +class SubZeroKarma(CantChangeKarma): + pass + + +class AutoLike(CantChangeKarma): pass -class AutoLike(KarmaError): +class DontOffendRestricted(CantChangeKarma): pass @@ -59,3 +67,14 @@ class InvalidFormatDuration(TimedeltaParseError): class NotHaveNeighbours(KarmaError): pass + + +class ModerationError(KarmaError): + def __init__(self, reason: str = None, type_event: str = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.reason = reason + self.type_event = type_event + + +class CantRestrict(ModerationError): + pass diff --git a/app/utils/from_axenia.py b/app/utils/from_axenia.py index 5101fa23..817d6cda 100644 --- a/app/utils/from_axenia.py +++ b/app/utils/from_axenia.py @@ -19,7 +19,7 @@ def parse_rating(html): name = tds[0].text try: username = username_by_link(tds[0].find('a').get('href')) - except Exception: + except AttributeError: username = None karma = tds[1].text yield name, username, karma diff --git a/app/utils/types.py b/app/utils/types.py new file mode 100644 index 00000000..511c1c87 --- /dev/null +++ b/app/utils/types.py @@ -0,0 +1,12 @@ +from typing import NamedTuple + +from app.models import UserKarma, KarmaEvent, ModeratorEvent + + +class ResultChangeKarma(NamedTuple): + user_karma: UserKarma + abs_change: float + karma_event: KarmaEvent + count_auto_restrict: int + karma_after: float + moderator_event: ModeratorEvent diff --git a/tests/karma/common.py b/tests/karma/common.py index 2397bd86..60669d2e 100644 --- a/tests/karma/common.py +++ b/tests/karma/common.py @@ -15,3 +15,9 @@ def filter_check(message: types.Message): karma_filter = KarmaFilter(karma_change=True) return asyncio.run(karma_filter.check(message)) + + +__all__ = [ + KarmaFilter, PUNCTUATIONS, PLUS_TRIGGERS, PLUS_EMOJI, MINUS_TRIGGERS, MINUS_EMOJI, PLUS, INF, + plus_texts, minus_texts, punctuations, SPACES, filter_check +]